├── tmp └── .gitkeep ├── examples ├── attachment ├── mail ├── plainmail ├── unknownmail ├── plainlinkmail ├── dotmail ├── htmlmail ├── xhtmlmail ├── multipartmail ├── multipartmail-with-utf8 ├── quoted_printable_htmlmail ├── breaking └── attachmail ├── public └── favicon.ico ├── Gemfile ├── assets ├── images │ ├── logo.png │ ├── logo_2x.png │ └── logo_large.png ├── stylesheets │ └── mailcatcher.css.sass └── javascripts │ └── mailcatcher.js.coffee ├── .dockerignore ├── lib ├── mail_catcher │ ├── version.rb │ ├── bus.rb │ ├── web │ │ ├── assets.rb │ │ └── application.rb │ ├── web.rb │ ├── smtp.rb │ └── mail.rb ├── mailcatcher.rb └── mail_catcher.rb ├── bin ├── mailcatcher └── catchmail ├── views ├── 404.erb └── index.erb ├── .gitignore ├── Dockerfile ├── spec ├── command_spec.rb ├── clear_spec.rb ├── quit_spec.rb ├── spec_helper.rb └── delivery_spec.rb ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── mailcatcher.gemspec ├── Rakefile ├── vendor └── assets │ └── javascripts │ ├── favcount.js │ ├── keymaster.js │ ├── modernizr.js │ ├── url.js │ └── date.js └── README.md /tmp/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/attachment: -------------------------------------------------------------------------------- 1 | Hello, I am an attachment! 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sj26/mailcatcher/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sj26/mailcatcher/HEAD/assets/images/logo.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # We don't use this repo's files to build the Docker image, we just gem install 2 | * 3 | -------------------------------------------------------------------------------- /assets/images/logo_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sj26/mailcatcher/HEAD/assets/images/logo_2x.png -------------------------------------------------------------------------------- /assets/images/logo_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sj26/mailcatcher/HEAD/assets/images/logo_large.png -------------------------------------------------------------------------------- /examples/mail: -------------------------------------------------------------------------------- 1 | To: Blah 2 | From: Me 3 | Subject: Test mail 4 | 5 | Test mail. 6 | -------------------------------------------------------------------------------- /lib/mail_catcher/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MailCatcher 4 | VERSION = "0.10.0" 5 | end 6 | -------------------------------------------------------------------------------- /lib/mailcatcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "mail_catcher" 4 | 5 | Mailcatcher = MailCatcher 6 | -------------------------------------------------------------------------------- /bin/mailcatcher: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'mail_catcher' 5 | 6 | MailCatcher.run! 7 | -------------------------------------------------------------------------------- /examples/plainmail: -------------------------------------------------------------------------------- 1 | To: Blah 2 | From: Me 3 | Subject: Plain mail 4 | Content-Type: text/plain 5 | 6 | Here's some text 7 | -------------------------------------------------------------------------------- /examples/unknownmail: -------------------------------------------------------------------------------- 1 | To: Blah 2 | From: Me 3 | Subject: Test mail 4 | Content-Type: application/x-weird 5 | 6 | Weird stuff~ 7 | -------------------------------------------------------------------------------- /lib/mail_catcher/bus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "eventmachine" 4 | 5 | module MailCatcher 6 | Bus = EventMachine::Channel.new 7 | end 8 | -------------------------------------------------------------------------------- /views/404.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 |

No Dice

4 |

The message you were looking for does not exist, or doesn't have content of this type.

5 | 6 | 7 | -------------------------------------------------------------------------------- /examples/plainlinkmail: -------------------------------------------------------------------------------- 1 | To: Blah 2 | From: Me 3 | Subject: Plain mail 4 | Content-Type: text/plain 5 | 6 | You "should" visit: 7 | 8 | https://mailcatcher.me 9 | -------------------------------------------------------------------------------- /examples/dotmail: -------------------------------------------------------------------------------- 1 | To: Blah 2 | From: Me 3 | Subject: Whatever 4 | Content-Type: text/plain 5 | 6 | Plain text mail 7 | 8 | With some dot lines: 9 | 10 | . 11 | 12 | ... 13 | 14 | Done. 15 | -------------------------------------------------------------------------------- /examples/htmlmail: -------------------------------------------------------------------------------- 1 | To: Blah 2 | From: Me 3 | Subject: Test HTML Mail 4 | Content-Type: text/html 5 | 6 | 7 | 8 | Yo, you slimey scoundrel. 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/xhtmlmail: -------------------------------------------------------------------------------- 1 | To: Blah 2 | From: Me 3 | Subject: Test XHTML Mail 4 | Content-Type: application/xhtml+xml 5 | 6 | 7 | 8 | Yo, you slimey scoundrel. 9 | 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Caches 2 | /.bundle 3 | /.sass-cache 4 | 5 | # Gemfile locks ignored for gems 6 | /Gemfile.lock 7 | 8 | # Generated documentation and assets 9 | /doc 10 | /public/assets 11 | 12 | # Build gems 13 | *.gem 14 | 15 | # Temp area, used for testing artifacts 16 | /tmp 17 | -------------------------------------------------------------------------------- /lib/mail_catcher/web/assets.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "sprockets" 4 | require "sprockets-sass" 5 | require "compass" 6 | 7 | module MailCatcher 8 | module Web 9 | Assets = Sprockets::Environment.new(File.expand_path("#{__FILE__}/../../../..")).tap do |sprockets| 10 | Dir["#{sprockets.root}/{,vendor}/assets/*"].each do |path| 11 | sprockets.append_path(path) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /examples/multipartmail: -------------------------------------------------------------------------------- 1 | To: Blah 2 | From: Me 3 | Subject: Test Multipart Mail 4 | Mime-Version: 1.0 5 | Content-Type: multipart/alternative; boundary=BOUNDARY--198849662 6 | 7 | Header 8 | 9 | --BOUNDARY--198849662 10 | Content-Type: text/plain 11 | 12 | Plain text mail 13 | 14 | --BOUNDARY--198849662 15 | Content-Type: text/html 16 | 17 | HTML mail 18 | 19 | --BOUNDARY--198849662-- 20 | -------------------------------------------------------------------------------- /examples/multipartmail-with-utf8: -------------------------------------------------------------------------------- 1 | To: Blah 2 | From: Me 3 | Subject: Test Multipart UTF8 Mail 4 | Mime-Version: 1.0 5 | Content-Type: multipart/alternative; boundary=BOUNDARY--198849662 6 | 7 | Header 8 | 9 | --BOUNDARY--198849662 10 | Content-Type: text/plain 11 | 12 | Plain text mail 13 | 14 | --BOUNDARY--198849662 15 | Content-Type: text/html 16 | 17 | © HTML mail 18 | 19 | --BOUNDARY--198849662-- 20 | -------------------------------------------------------------------------------- /examples/quoted_printable_htmlmail: -------------------------------------------------------------------------------- 1 | To: Blah 2 | From: Me 3 | Subject: Test quoted-printable HTML mail 4 | Mime-Version: 1.0 5 | Content-Type: text/html; 6 | charset=UTF-8 7 | Content-Transfer-Encoding: quoted-printable 8 | 9 | 10 |

11 | Thank you for allowing Grand Rounds to provide a test case that ma= 12 | y demonstrate a limitation in MailCatcher. Open source makes dev good= 13 | 14 |

15 |

16 | You can access an error at here 18 |

= 19 | -------------------------------------------------------------------------------- /examples/breaking: -------------------------------------------------------------------------------- 1 | Date: Wed, 08 Jan 2014 06:52:20 +0000 2 | From: survey@place.com 3 | Reply-To: Support support@someplace.com 4 | To: asdfasdf@asdfasdf.com 5 | Message-ID: 6 | Subject: Subject 7 | Mime-Version: 1.0 8 | Content-Type: text/html; 9 | charset=UTF-8 10 | Content-Transfer-Encoding: 7bit 11 | 12 | 13 | 14 | First Name:Asdf 15 | 16 | 17 | 18 | Last Name:Asdf 19 | 20 | 21 | 22 | Full Name:Asdf Asdf 23 | 24 | 25 | 26 | Formal Name:Mr. Asdf Asdf 27 | 28 | 29 | 30 | Company Name:Some Company 31 | 32 | 33 | 34 | URL: http://localhost:3000/surveys/er2014/en 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.3-alpine 2 | MAINTAINER Samuel Cochran 3 | 4 | # Use --build-arg VERSION=... to override 5 | # or `rake docker VERSION=...` 6 | ARG VERSION=0.10.0 7 | 8 | # sqlite3 aarch64 is broken on alpine, so use ruby: 9 | # https://github.com/sparklemotion/sqlite3-ruby/issues/372 10 | RUN apk add --no-cache build-base sqlite-libs sqlite-dev && \ 11 | ( [ "$(uname -m)" != "aarch64" ] || gem install sqlite3 --version="~> 1.3" --platform=ruby ) && \ 12 | gem install mailcatcher -v "$VERSION" && \ 13 | apk del --rdepends --purge build-base sqlite-dev 14 | 15 | EXPOSE 1025 1080 16 | 17 | ENTRYPOINT ["mailcatcher", "--foreground"] 18 | CMD ["--ip", "0.0.0.0"] 19 | -------------------------------------------------------------------------------- /spec/command_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe "mailcatcher command" do 4 | context "--version" do 5 | it "shows a version then exits" do 6 | expect { system %(mailcatcher --version) } 7 | .to output(a_string_including("MailCatcher v#{MailCatcher::VERSION}")) 8 | .to_stdout_from_any_process 9 | end 10 | end 11 | 12 | context "--help" do 13 | it "shows help then exits" do 14 | expect { system %(mailcatcher --help) } 15 | .to output(a_string_including("MailCatcher v#{MailCatcher::VERSION}") & a_string_including("--help") & a_string_including("Display this help information")) 16 | .to_stdout_from_any_process 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/mail_catcher/web.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/builder" 4 | 5 | require "mail_catcher/web/application" 6 | 7 | module MailCatcher 8 | module Web extend self 9 | def app 10 | @@app ||= Rack::Builder.new do 11 | map(MailCatcher.options[:http_path]) do 12 | if MailCatcher.development? 13 | require "mail_catcher/web/assets" 14 | map("/assets") { run Assets } 15 | end 16 | 17 | run Application 18 | end 19 | 20 | # This should only affect when http_path is anything but "/" above 21 | run lambda { |env| [302, {"Location" => MailCatcher.options[:http_path]}, []] } 22 | end 23 | end 24 | 25 | def call(env) 26 | app.call(env) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /examples/attachmail: -------------------------------------------------------------------------------- 1 | From: Me 2 | To: Blah 3 | Message-ID: <6222a4f8c433d_2d0b11445485@some.localdomain.mail> 4 | Subject: Test Attachment Mail 5 | Mime-Version: 1.0 6 | Content-Type: multipart/mixed; 7 | boundary="--==_mimepart_6222a498576e8_2d0b114453fc"; 8 | charset=UTF-8 9 | Content-Transfer-Encoding: 7bit 10 | 11 | 12 | ----==_mimepart_6222a498576e8_2d0b114453fc 13 | Content-Type: text/plain; 14 | charset=UTF-8 15 | Content-Transfer-Encoding: 7bit 16 | 17 | This is plain text 18 | ----==_mimepart_6222a498576e8_2d0b114453fc 19 | Content-Type: text/plain; 20 | charset=UTF-8 21 | Content-Transfer-Encoding: base64 22 | Content-Disposition: attachment; 23 | filename=attachment 24 | Content-ID: <6222a4f8c512a_2d0b11445564@some.localdomain.mail> 25 | 26 | SGVsbG8sIEkgYW0gYW4gYXR0YWNobWVudCENCg== 27 | 28 | ----==_mimepart_6222a498576e8_2d0b114453fc-- 29 | -------------------------------------------------------------------------------- /spec/clear_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Clear", type: :feature do 6 | it "clears all messages" do 7 | # Delivering three emails .. 8 | deliver_example("plainmail") 9 | deliver_example("plainmail") 10 | deliver_example("plainmail") 11 | 12 | # .. should display three emails 13 | expect(page).to have_selector("#messages table tbody tr", text: "Plain mail", count: 3) 14 | 15 | # Clicking Clear but cancelling .. 16 | dismiss_confirm do 17 | click_on "Clear" 18 | end 19 | 20 | # .. should still display three emails 21 | expect(page).to have_selector("#messages table tbody tr", text: "Plain mail", count: 3) 22 | 23 | # Clicking clear and confirming .. 24 | accept_confirm "Are you sure you want to clear all messages?" do 25 | click_on "Clear" 26 | end 27 | 28 | # .. should display no emails 29 | expect(page).not_to have_selector("#messages table tbody tr") 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: ~ 9 | 10 | jobs: 11 | test: 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, macos-latest] 15 | ruby-version: ['3.1', '3.2', '3.3'] 16 | 17 | runs-on: ${{ matrix.os }} 18 | timeout-minutes: 10 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby-version }} 26 | bundler-cache: true 27 | 28 | - uses: actions/setup-node@v3 29 | 30 | - uses: browser-actions/setup-chrome@latest 31 | - uses: nanasess/setup-chromedriver@master 32 | 33 | - name: Run tests 34 | run: bundle exec rake test 35 | timeout-minutes: 5 36 | 37 | - name: Upload test artifacts 38 | uses: actions/upload-artifact@v3 39 | if: always() 40 | with: 41 | name: test-artifacts 42 | path: tmp 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2011 Samuel Cochran 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /spec/quit_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Quit", type: :feature do 6 | it "quits cleanly via the Quit button" do 7 | # Quitting and cancelling .. 8 | dismiss_confirm do 9 | click_on "Quit" 10 | end 11 | 12 | # .. should not exit the process 13 | expect { Process.kill(0, @pid) }.not_to raise_error 14 | 15 | # Reload the page to be sure 16 | visit "/" 17 | wait.until { page.evaluate_script("MailCatcher.websocket.readyState") == 1 rescue false } 18 | 19 | # Quitting and confirming .. 20 | accept_confirm "Are you sure you want to quit?" do 21 | click_on "Quit" 22 | end 23 | 24 | # .. should exit the process .. 25 | _, status = Process.wait2(@pid) 26 | 27 | expect(status).to be_exited 28 | expect(status).to be_success 29 | 30 | # .. and navigate to the mailcatcher website 31 | expect(page).to have_current_path "https://mailcatcher.me" 32 | end 33 | 34 | it "quits cleanly on Ctrl+C" do 35 | # Sending a SIGINT (Ctrl+C) ... 36 | Process.kill(:SIGINT, @pid) 37 | 38 | # .. should cause the process to exit cleanly 39 | _, status = Process.wait2(@pid) 40 | 41 | expect(status).to be_exited 42 | expect(status).to be_success 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /bin/catchmail: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | begin 5 | require 'mail' 6 | rescue LoadError 7 | require 'rubygems' 8 | require 'mail' 9 | end 10 | 11 | require 'optparse' 12 | 13 | options = {:smtp_ip => '127.0.0.1', :smtp_port => 1025} 14 | 15 | OptionParser.new do |parser| 16 | parser.banner = <<-BANNER.gsub /^ +/, "" 17 | Usage: catchmail [options] [recipient ...] 18 | sendmail-like interface to forward mail to MailCatcher. 19 | BANNER 20 | 21 | parser.on('--ip IP') do |ip| 22 | options[:smtp_ip] = ip 23 | end 24 | 25 | parser.on('--smtp-ip IP', 'Set the ip address of the smtp server') do |ip| 26 | options[:smtp_ip] = ip 27 | end 28 | 29 | parser.on('--smtp-port PORT', Integer, 'Set the port of the smtp server') do |port| 30 | options[:smtp_port] = port 31 | end 32 | 33 | parser.on('-f FROM', 'Set the sending address') do |from| 34 | options[:from] = from 35 | end 36 | 37 | parser.on('-oi', 'Ignored option -oi') do |ignored| 38 | end 39 | parser.on('-t', 'Ignored option -t') do |ignored| 40 | end 41 | parser.on('-q', 'Ignored option -q') do |ignored| 42 | end 43 | 44 | parser.on('-x', '--no-exit', 'Can\'t exit from the application') do 45 | options[:no_exit] = true 46 | end 47 | 48 | parser.on('-h', '--help', 'Display this help information') do 49 | puts parser 50 | exit! 51 | end 52 | end.parse! 53 | 54 | Mail.defaults do 55 | delivery_method :smtp, 56 | :address => options[:smtp_ip], 57 | :port => options[:smtp_port] 58 | end 59 | 60 | message = Mail.new($stdin.read) 61 | 62 | message.return_path = options[:from] if options[:from] 63 | 64 | ARGV.each do |recipient| 65 | if message.to.nil? 66 | message.to = recipient 67 | else 68 | message.to << recipient 69 | end 70 | end 71 | 72 | message.deliver 73 | -------------------------------------------------------------------------------- /lib/mail_catcher/smtp.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "eventmachine" 4 | 5 | require "mail_catcher/mail" 6 | 7 | class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer 8 | # We override EM's mail from processing to allow multiple mail-from commands 9 | # per [RFC 2821](https://tools.ietf.org/html/rfc2821#section-4.1.1.2) 10 | def process_mail_from sender 11 | if @state.include? :mail_from 12 | @state -= [:mail_from, :rcpt, :data] 13 | 14 | receive_reset 15 | end 16 | 17 | super 18 | end 19 | 20 | def current_message 21 | @current_message ||= {} 22 | end 23 | 24 | def receive_reset 25 | @current_message = nil 26 | 27 | true 28 | end 29 | 30 | def receive_sender(sender) 31 | # EventMachine SMTP advertises size extensions [https://tools.ietf.org/html/rfc1870] 32 | # so strip potential " SIZE=..." suffixes from senders 33 | sender = $` if sender =~ / SIZE=\d+\z/ 34 | 35 | current_message[:sender] = sender 36 | 37 | true 38 | end 39 | 40 | def receive_recipient(recipient) 41 | current_message[:recipients] ||= [] 42 | current_message[:recipients] << recipient 43 | 44 | true 45 | end 46 | 47 | def receive_data_chunk(lines) 48 | current_message[:source] ||= +"" 49 | 50 | lines.each do |line| 51 | current_message[:source] << line << "\r\n" 52 | end 53 | 54 | true 55 | end 56 | 57 | def receive_message 58 | MailCatcher::Mail.add_message current_message 59 | MailCatcher::Mail.delete_older_messages! 60 | puts "==> SMTP: Received message from '#{current_message[:sender]}' (#{current_message[:source].length} bytes)" 61 | true 62 | rescue => exception 63 | MailCatcher.log_exception("Error receiving message", @current_message, exception) 64 | false 65 | ensure 66 | @current_message = nil 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /mailcatcher.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path("../lib/mail_catcher/version", __FILE__) 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "mailcatcher" 7 | s.version = MailCatcher::VERSION 8 | s.license = "MIT" 9 | s.summary = "Runs an SMTP server, catches and displays email in a web interface." 10 | s.description = <<-END 11 | MailCatcher runs a super simple SMTP server which catches any 12 | message sent to it to display in a web interface. Run 13 | mailcatcher, set your favourite app to deliver to 14 | smtp://127.0.0.1:1025 instead of your default SMTP server, 15 | then check out http://127.0.0.1:1080 to see the mail. 16 | END 17 | 18 | s.author = "Samuel Cochran" 19 | s.email = "sj26@sj26.com" 20 | s.homepage = "https://mailcatcher.me" 21 | 22 | s.files = Dir[ 23 | "README.md", "LICENSE", "VERSION", 24 | "bin/*", 25 | "lib/**/*.rb", 26 | "public/**/*", 27 | "views/**/*", 28 | ] - Dir["lib/mail_catcher/web/assets.rb"] 29 | s.require_paths = ["lib"] 30 | s.executables = ["mailcatcher", "catchmail"] 31 | s.extra_rdoc_files = ["README.md", "LICENSE"] 32 | 33 | s.required_ruby_version = ">= 3.1" 34 | 35 | s.add_dependency "eventmachine", "~> 1.0" 36 | s.add_dependency "faye-websocket", "~> 0.11.1" 37 | s.add_dependency "mail", "~> 2.3" 38 | s.add_dependency "net-smtp" 39 | s.add_dependency "rack", "~> 2.2" 40 | s.add_dependency "sinatra", "~> 3.2" 41 | s.add_dependency "sqlite3", "~> 1.3" 42 | s.add_dependency "thin", "~> 1.8" 43 | 44 | s.add_development_dependency "capybara" 45 | s.add_development_dependency "capybara-screenshot" 46 | s.add_development_dependency "coffee-script" 47 | s.add_development_dependency "compass", "~> 1.0.3" 48 | s.add_development_dependency "rspec" 49 | s.add_development_dependency "rake" 50 | s.add_development_dependency "rdoc" 51 | s.add_development_dependency "sass" 52 | s.add_development_dependency "selenium-webdriver" 53 | s.add_development_dependency "sprockets" 54 | s.add_development_dependency "sprockets-sass" 55 | s.add_development_dependency "sprockets-helpers" 56 | s.add_development_dependency "uglifier" 57 | end 58 | -------------------------------------------------------------------------------- /views/index.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | MailCatcher 5 | /"> 6 | 7 | 8 | "> 9 | 10 | 11 | 18 |
19 |

MailCatcher

20 | 29 |
30 | 43 |
44 |
45 |
46 | 58 | 66 |
67 | 68 |
69 | 70 | 71 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fileutils" 4 | require "rubygems" 5 | 6 | require "mail_catcher/version" 7 | 8 | # XXX: Would prefer to use Rake::SprocketsTask but can't populate 9 | # non-digest assets, and we don't want sprockets at runtime so 10 | # can't use manifest directly. Perhaps index.html should be 11 | # precompiled with digest assets paths? 12 | 13 | desc "Compile assets" 14 | task "assets" do 15 | compiled_path = File.expand_path("../public/assets", __FILE__) 16 | FileUtils.mkdir_p(compiled_path) 17 | 18 | require "mail_catcher/web/assets" 19 | sprockets = MailCatcher::Web::Assets 20 | sprockets.css_compressor = :sass 21 | sprockets.js_compressor = :uglifier 22 | sprockets.each_logical_path(/(\Amailcatcher\.(js|css)|\.(xsl|png)\Z)/) do |logical_path| 23 | if asset = sprockets.find_asset(logical_path) 24 | target = File.join(compiled_path, logical_path) 25 | asset.write_to target 26 | end 27 | end 28 | end 29 | 30 | desc "Package as Gem" 31 | task "package" => ["assets"] do 32 | require "rubygems/package" 33 | require "rubygems/specification" 34 | 35 | spec_file = File.expand_path("../mailcatcher.gemspec", __FILE__) 36 | spec = Gem::Specification.load(spec_file) 37 | 38 | Gem::Package.build spec 39 | end 40 | 41 | desc "Release Gem to RubyGems" 42 | task "release" => ["package"] do 43 | %x[gem push mailcatcher-#{MailCatcher::VERSION}.gem] 44 | end 45 | 46 | desc "Build and push Docker images (optional: VERSION=#{MailCatcher::VERSION})" 47 | task "docker" do 48 | version = ENV.fetch("VERSION", MailCatcher::VERSION) 49 | 50 | Dir.chdir(__dir__) do 51 | system "docker", "buildx", "build", 52 | # Push straight to Docker Hub (only way to do multi-arch??) 53 | "--push", 54 | # Build for both intel and arm (apple, graviton, etc) 55 | "--platform", "linux/amd64", 56 | "--platform", "linux/arm64", 57 | # Version respected within Dockerfile 58 | "--build-arg", "VERSION=#{version}", 59 | # Push latest and version 60 | "-t", "sj26/mailcatcher:latest", 61 | "-t", "sj26/mailcatcher:v#{version}", 62 | # Use current dir as context 63 | "." 64 | end 65 | end 66 | 67 | require "rdoc/task" 68 | 69 | RDoc::Task.new(:rdoc => "doc",:clobber_rdoc => "doc:clean", :rerdoc => "doc:force") do |rdoc| 70 | rdoc.title = "MailCatcher #{MailCatcher::VERSION}" 71 | rdoc.rdoc_dir = "doc" 72 | rdoc.main = "README.md" 73 | rdoc.rdoc_files.include "lib/**/*.rb" 74 | end 75 | 76 | require "rspec/core/rake_task" 77 | 78 | RSpec::Core::RakeTask.new(:test) do |rspec| 79 | rspec.rspec_opts = "--format doc" 80 | end 81 | 82 | task :test => :assets 83 | 84 | task :default => :test 85 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/favcount.js: -------------------------------------------------------------------------------- 1 | /* 2 | * favcount.js v1.5.0 3 | * http://chrishunt.co/favcount 4 | * Dynamically updates the favicon with a number. 5 | * 6 | * Copyright 2013, Chris Hunt 7 | * Released under the MIT license 8 | */ 9 | 10 | (function(){ 11 | function Favcount(icon) { 12 | this.icon = icon; 13 | this.opacity = 0.4; 14 | this.canvas = document.createElement('canvas'); 15 | this.font = "Helvetica, Arial, sans-serif"; 16 | } 17 | 18 | Favcount.prototype.set = function(count) { 19 | var self = this, 20 | img = document.createElement('img'); 21 | 22 | if (self.canvas.getContext) { 23 | img.crossOrigin = "anonymous"; 24 | 25 | img.onload = function() { 26 | drawCanvas(self.canvas, self.opacity, self.font, img, normalize(count)); 27 | }; 28 | 29 | img.src = this.icon; 30 | } 31 | }; 32 | 33 | function normalize(count) { 34 | count = Math.round(count); 35 | 36 | if (isNaN(count) || count < 1) { 37 | return ''; 38 | } else if (count < 10) { 39 | return ' ' + count; 40 | } else if (count > 99) { 41 | return '99'; 42 | } else { 43 | return count; 44 | } 45 | } 46 | 47 | function drawCanvas(canvas, opacity, font, img, count) { 48 | var head = document.getElementsByTagName('head')[0], 49 | favicon = document.createElement('link'), 50 | multiplier, fontSize, context, xOffset, yOffset, border, shadow; 51 | 52 | favicon.rel = 'icon'; 53 | 54 | // Scale canvas elements based on favicon size 55 | multiplier = img.width / 16; 56 | fontSize = multiplier * 11; 57 | xOffset = multiplier; 58 | yOffset = multiplier * 11; 59 | border = multiplier; 60 | shadow = multiplier * 2; 61 | 62 | canvas.height = canvas.width = img.width; 63 | context = canvas.getContext('2d'); 64 | context.font = 'bold ' + fontSize + 'px ' + font; 65 | 66 | // Draw faded favicon background 67 | if (count) { context.globalAlpha = opacity; } 68 | context.drawImage(img, 0, 0); 69 | context.globalAlpha = 1.0; 70 | 71 | // Draw white drop shadow 72 | context.shadowColor = '#FFF'; 73 | context.shadowBlur = shadow; 74 | context.shadowOffsetX = 0; 75 | context.shadowOffsetY = 0; 76 | 77 | // Draw white border 78 | context.fillStyle = '#FFF'; 79 | context.fillText(count, xOffset, yOffset); 80 | context.fillText(count, xOffset + border, yOffset); 81 | context.fillText(count, xOffset, yOffset + border); 82 | context.fillText(count, xOffset + border, yOffset + border); 83 | 84 | // Draw black count 85 | context.fillStyle = '#000'; 86 | context.fillText(count, 87 | xOffset + (border / 2.0), 88 | yOffset + (border / 2.0) 89 | ); 90 | 91 | // Replace favicon with new favicon 92 | favicon.href = canvas.toDataURL('image/png'); 93 | head.removeChild(document.querySelector('link[rel=icon]')); 94 | head.appendChild(favicon); 95 | } 96 | 97 | this.Favcount = Favcount; 98 | }).call(this); 99 | 100 | (function(){ 101 | Favcount.VERSION = '1.5.0'; 102 | }).call(this); 103 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["MAILCATCHER_ENV"] ||= "test" 4 | 5 | require "capybara/rspec" 6 | require "capybara-screenshot/rspec" 7 | require "selenium/webdriver" 8 | 9 | require "net/smtp" 10 | require "socket" 11 | 12 | require "mail_catcher" 13 | 14 | DEFAULT_FROM = "from@example.com" 15 | DEFAULT_TO = "to@example.com" 16 | 17 | LOCALHOST = "127.0.0.1" 18 | SMTP_PORT = 20025 19 | HTTP_PORT = 20080 20 | 21 | # Use headless chrome by default 22 | Capybara.default_driver = :selenium 23 | Capybara.register_driver :selenium do |app| 24 | opts = Selenium::WebDriver::Chrome::Options.new 25 | 26 | opts.add_argument('disable-gpu') 27 | opts.add_argument('force-device-scale-factor=1') 28 | opts.add_argument('window-size=1400,900') 29 | 30 | # Use NO_HEADLESS to open real chrome when debugging tests 31 | unless ENV["NO_HEADLESS"] 32 | opts.add_argument('headless=new') 33 | end 34 | 35 | Capybara::Selenium::Driver.new app, browser: :chrome, 36 | service: Selenium::WebDriver::Service.chrome(log: File.expand_path("../tmp/chromedriver.log", __dir__)), 37 | options: opts 38 | end 39 | 40 | Capybara.configure do |config| 41 | # Don't start a rack server, connect to mailcatcher process 42 | config.run_server = false 43 | 44 | # Give a little more leeway for slow compute in CI 45 | config.default_max_wait_time = 10 if ENV["CI"] 46 | 47 | # Save into tmp directory 48 | config.save_path = File.expand_path("../tmp/capybara", __dir__) 49 | end 50 | 51 | # Tell Capybara to talk to mailcatcher 52 | Capybara.app_host = "http://#{LOCALHOST}:#{HTTP_PORT}" 53 | 54 | RSpec.configure do |config| 55 | # Helpers for delivering example email 56 | def deliver(message, options={}) 57 | options = {:from => DEFAULT_FROM, :to => DEFAULT_TO}.merge(options) 58 | Net::SMTP.start(LOCALHOST, SMTP_PORT) do |smtp| 59 | smtp.send_message message, options[:from], options[:to] 60 | end 61 | end 62 | 63 | def read_example(name) 64 | File.read(File.expand_path("../../examples/#{name}", __FILE__)) 65 | end 66 | 67 | def deliver_example(name, options={}) 68 | deliver(read_example(name), options) 69 | end 70 | 71 | # Teach RSpec to gather console errors from chrome when there are failures 72 | config.after(:each, type: :feature) do |example| 73 | # Did the example fail? 74 | next unless example.exception # "failed" 75 | 76 | # Do we have a browser? 77 | next unless page.driver.browser 78 | 79 | # Retrieve console logs if the browser/driver supports it 80 | logs = page.driver.browser.manage.logs.get(:browser) rescue [] 81 | 82 | # Anything to report? 83 | next if logs.empty? 84 | 85 | # Add the log messages so they appear in failures 86 | 87 | # This might already be a string, an array, or nothing 88 | # Array(nil) => [], Array("a") => ["a"], Array(["a", "b"]) => ["a", "b"] 89 | lines = example.metadata[:extra_failure_lines] = Array(example.metadata[:extra_failure_lines]) 90 | 91 | # Add a gap if there's anything there and it doesn't end with an empty line 92 | lines << "" if lines.last 93 | 94 | lines << "Browser console errors:" 95 | lines << JSON.pretty_generate(logs.map { |log| log.as_json }) 96 | end 97 | 98 | def wait 99 | Selenium::WebDriver::Wait.new 100 | end 101 | 102 | config.before :each, type: :feature do 103 | # Start MailCatcher 104 | @pid = spawn "bundle", "exec", "mailcatcher", "--foreground", "--smtp-port", SMTP_PORT.to_s, "--http-port", HTTP_PORT.to_s 105 | 106 | # Wait for it to boot 107 | begin 108 | Socket.tcp(LOCALHOST, SMTP_PORT, connect_timeout: 1) { |s| s.close } 109 | Socket.tcp(LOCALHOST, HTTP_PORT, connect_timeout: 1) { |s| s.close } 110 | rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT 111 | retry 112 | end 113 | 114 | # Open the web interface 115 | visit "/" 116 | 117 | # Wait for the websocket to be available to avoid race conditions 118 | wait.until { page.evaluate_script("MailCatcher.websocket.readyState") == 1 rescue false } 119 | end 120 | 121 | config.after :each, type: :feature do 122 | # Quit MailCatcher 123 | Process.kill("TERM", @pid) 124 | Process.wait 125 | rescue Errno::ESRCH 126 | # It's already gone 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/mail_catcher/web/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pathname" 4 | require "net/http" 5 | require "uri" 6 | 7 | require "faye/websocket" 8 | require "sinatra" 9 | 10 | require "mail_catcher/bus" 11 | require "mail_catcher/mail" 12 | 13 | Faye::WebSocket.load_adapter("thin") 14 | 15 | # Faye's adapter isn't smart enough to close websockets when thin is stopped, 16 | # so we teach it to do so. 17 | class Thin::Backends::Base 18 | alias :thin_stop :stop 19 | 20 | def stop 21 | thin_stop 22 | @connections.each_value do |connection| 23 | if connection.socket_stream 24 | connection.socket_stream.close_connection_after_writing 25 | end 26 | end 27 | end 28 | end 29 | 30 | class Sinatra::Request 31 | include Faye::WebSocket::Adapter 32 | end 33 | 34 | module MailCatcher 35 | module Web 36 | class Application < Sinatra::Base 37 | set :environment, MailCatcher.env 38 | set :prefix, MailCatcher.options[:http_path] 39 | set :asset_prefix, File.join(prefix, "assets") 40 | set :root, File.expand_path("#{__FILE__}/../../../..") 41 | 42 | if development? 43 | require "sprockets-helpers" 44 | 45 | configure do 46 | require "mail_catcher/web/assets" 47 | Sprockets::Helpers.configure do |config| 48 | config.environment = Assets 49 | config.prefix = settings.asset_prefix 50 | config.digest = false 51 | config.public_path = public_folder 52 | config.debug = true 53 | end 54 | end 55 | 56 | helpers do 57 | include Sprockets::Helpers 58 | end 59 | else 60 | helpers do 61 | def asset_path(filename) 62 | File.join(settings.asset_prefix, filename) 63 | end 64 | end 65 | end 66 | 67 | get "/" do 68 | erb :index 69 | end 70 | 71 | delete "/" do 72 | if MailCatcher.quittable? 73 | MailCatcher.quit! 74 | status 204 75 | else 76 | status 403 77 | end 78 | end 79 | 80 | get "/messages" do 81 | if request.websocket? 82 | bus_subscription = nil 83 | 84 | ws = Faye::WebSocket.new(request.env) 85 | ws.on(:open) do |_| 86 | bus_subscription = MailCatcher::Bus.subscribe do |message| 87 | begin 88 | ws.send(JSON.generate(message)) 89 | rescue => exception 90 | MailCatcher.log_exception("Error sending message through websocket", message, exception) 91 | end 92 | end 93 | end 94 | 95 | ws.on(:close) do |_| 96 | MailCatcher::Bus.unsubscribe(bus_subscription) if bus_subscription 97 | end 98 | 99 | ws.rack_response 100 | else 101 | content_type :json 102 | JSON.generate(Mail.messages) 103 | end 104 | end 105 | 106 | delete "/messages" do 107 | Mail.delete! 108 | status 204 109 | end 110 | 111 | get "/messages/:id.json" do 112 | id = params[:id].to_i 113 | if message = Mail.message(id) 114 | content_type :json 115 | JSON.generate(message.merge({ 116 | "formats" => [ 117 | "source", 118 | ("html" if Mail.message_has_html? id), 119 | ("plain" if Mail.message_has_plain? id) 120 | ].compact, 121 | "attachments" => Mail.message_attachments(id), 122 | })) 123 | else 124 | not_found 125 | end 126 | end 127 | 128 | get "/messages/:id.html" do 129 | id = params[:id].to_i 130 | if part = Mail.message_part_html(id) 131 | content_type :html, :charset => (part["charset"] || "utf8") 132 | 133 | body = part["body"] 134 | 135 | # Rewrite body to link to embedded attachments served by cid 136 | body.gsub! /cid:([^'"> ]+)/, "#{id}/parts/\\1" 137 | 138 | body 139 | else 140 | not_found 141 | end 142 | end 143 | 144 | get "/messages/:id.plain" do 145 | id = params[:id].to_i 146 | if part = Mail.message_part_plain(id) 147 | content_type part["type"], :charset => (part["charset"] || "utf8") 148 | part["body"] 149 | else 150 | not_found 151 | end 152 | end 153 | 154 | get "/messages/:id.source" do 155 | id = params[:id].to_i 156 | if message_source = Mail.message_source(id) 157 | content_type "text/plain" 158 | message_source 159 | else 160 | not_found 161 | end 162 | end 163 | 164 | get "/messages/:id.eml" do 165 | id = params[:id].to_i 166 | if message_source = Mail.message_source(id) 167 | content_type "message/rfc822" 168 | message_source 169 | else 170 | not_found 171 | end 172 | end 173 | 174 | get "/messages/:id/parts/:cid" do 175 | id = params[:id].to_i 176 | if part = Mail.message_part_cid(id, params[:cid]) 177 | content_type part["type"], :charset => (part["charset"] || "utf8") 178 | attachment part["filename"] if part["is_attachment"] == 1 179 | body part["body"].to_s 180 | else 181 | not_found 182 | end 183 | end 184 | 185 | delete "/messages/:id" do 186 | id = params[:id].to_i 187 | if Mail.message(id) 188 | Mail.delete_message!(id) 189 | status 204 190 | else 191 | not_found 192 | end 193 | end 194 | 195 | not_found do 196 | erb :"404" 197 | end 198 | end 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /lib/mail_catcher/mail.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "eventmachine" 4 | require "json" 5 | require "mail" 6 | require "sqlite3" 7 | 8 | module MailCatcher::Mail extend self 9 | def db 10 | @__db ||= begin 11 | SQLite3::Database.new(":memory:", :type_translation => true).tap do |db| 12 | db.execute(<<-SQL) 13 | CREATE TABLE message ( 14 | id INTEGER PRIMARY KEY ASC, 15 | sender TEXT, 16 | recipients TEXT, 17 | subject TEXT, 18 | source BLOB, 19 | size TEXT, 20 | type TEXT, 21 | created_at DATETIME DEFAULT CURRENT_DATETIME 22 | ) 23 | SQL 24 | db.execute(<<-SQL) 25 | CREATE TABLE message_part ( 26 | id INTEGER PRIMARY KEY ASC, 27 | message_id INTEGER NOT NULL, 28 | cid TEXT, 29 | type TEXT, 30 | is_attachment INTEGER, 31 | filename TEXT, 32 | charset TEXT, 33 | body BLOB, 34 | size INTEGER, 35 | created_at DATETIME DEFAULT CURRENT_DATETIME, 36 | FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE 37 | ) 38 | SQL 39 | db.execute("PRAGMA foreign_keys = ON") 40 | end 41 | end 42 | end 43 | 44 | def add_message(message) 45 | @add_message_query ||= db.prepare("INSERT INTO message (sender, recipients, subject, source, type, size, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))") 46 | 47 | mail = Mail.new(message[:source]) 48 | @add_message_query.execute(message[:sender], JSON.generate(message[:recipients]), mail.subject, message[:source], mail.mime_type || "text/plain", message[:source].length) 49 | message_id = db.last_insert_row_id 50 | parts = mail.all_parts 51 | parts = [mail] if parts.empty? 52 | parts.each do |part| 53 | body = part.body.to_s 54 | # Only parts have CIDs, not mail 55 | cid = part.cid if part.respond_to? :cid 56 | add_message_part(message_id, cid, part.mime_type || "text/plain", part.attachment? ? 1 : 0, part.filename, part.charset, body, body.length) 57 | end 58 | 59 | EventMachine.next_tick do 60 | message = MailCatcher::Mail.message message_id 61 | MailCatcher::Bus.push(type: "add", message: message) 62 | end 63 | end 64 | 65 | def add_message_part(*args) 66 | @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'))" 67 | @add_message_part_query.execute(*args) 68 | end 69 | 70 | def latest_created_at 71 | @latest_created_at_query ||= db.prepare "SELECT created_at FROM message ORDER BY created_at DESC LIMIT 1" 72 | @latest_created_at_query.execute.next 73 | end 74 | 75 | def messages 76 | @messages_query ||= db.prepare "SELECT id, sender, recipients, subject, size, created_at FROM message ORDER BY created_at, id ASC" 77 | @messages_query.execute.map do |row| 78 | Hash[row.fields.zip(row)].tap do |message| 79 | message["recipients"] &&= JSON.parse(message["recipients"]) 80 | end 81 | end 82 | end 83 | 84 | def message(id) 85 | @message_query ||= db.prepare "SELECT id, sender, recipients, subject, size, type, created_at FROM message WHERE id = ? LIMIT 1" 86 | row = @message_query.execute(id).next 87 | row && Hash[row.fields.zip(row)].tap do |message| 88 | message["recipients"] &&= JSON.parse(message["recipients"]) 89 | end 90 | end 91 | 92 | def message_source(id) 93 | @message_source_query ||= db.prepare "SELECT source FROM message WHERE id = ? LIMIT 1" 94 | row = @message_source_query.execute(id).next 95 | row && row.first 96 | end 97 | 98 | def message_has_html?(id) 99 | @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" 100 | (!!@message_has_html_query.execute(id).next) || ["text/html", "application/xhtml+xml"].include?(message(id)["type"]) 101 | end 102 | 103 | def message_has_plain?(id) 104 | @message_has_plain_query ||= db.prepare "SELECT 1 FROM message_part WHERE message_id = ? AND is_attachment = 0 AND type = 'text/plain' LIMIT 1" 105 | (!!@message_has_plain_query.execute(id).next) || message(id)["type"] == "text/plain" 106 | end 107 | 108 | def message_parts(id) 109 | @message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? ORDER BY filename ASC" 110 | @message_parts_query.execute(id).map do |row| 111 | Hash[row.fields.zip(row)] 112 | end 113 | end 114 | 115 | def message_attachments(id) 116 | @message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? AND is_attachment = 1 ORDER BY filename ASC" 117 | @message_parts_query.execute(id).map do |row| 118 | Hash[row.fields.zip(row)] 119 | end 120 | end 121 | 122 | def message_part(message_id, part_id) 123 | @message_part_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND id = ? LIMIT 1" 124 | row = @message_part_query.execute(message_id, part_id).next 125 | row && Hash[row.fields.zip(row)] 126 | end 127 | 128 | def message_part_type(message_id, part_type) 129 | @message_part_type_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND type = ? AND is_attachment = 0 LIMIT 1" 130 | row = @message_part_type_query.execute(message_id, part_type).next 131 | row && Hash[row.fields.zip(row)] 132 | end 133 | 134 | def message_part_html(message_id) 135 | part = message_part_type(message_id, "text/html") 136 | part ||= message_part_type(message_id, "application/xhtml+xml") 137 | part ||= begin 138 | message = message(message_id) 139 | message if message and ["text/html", "application/xhtml+xml"].include? message["type"] 140 | end 141 | end 142 | 143 | def message_part_plain(message_id) 144 | message_part_type message_id, "text/plain" 145 | end 146 | 147 | def message_part_cid(message_id, cid) 148 | @message_part_cid_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ?" 149 | @message_part_cid_query.execute(message_id).map do |row| 150 | Hash[row.fields.zip(row)] 151 | end.find do |part| 152 | part["cid"] == cid 153 | end 154 | end 155 | 156 | def delete! 157 | @delete_all_messages_query ||= db.prepare "DELETE FROM message" 158 | @delete_all_messages_query.execute 159 | 160 | EventMachine.next_tick do 161 | MailCatcher::Bus.push(type: "clear") 162 | end 163 | end 164 | 165 | def delete_message!(message_id) 166 | @delete_messages_query ||= db.prepare "DELETE FROM message WHERE id = ?" 167 | @delete_messages_query.execute(message_id) 168 | 169 | EventMachine.next_tick do 170 | MailCatcher::Bus.push(type: "remove", id: message_id) 171 | end 172 | end 173 | 174 | def delete_older_messages!(count = MailCatcher.options[:messages_limit]) 175 | return if count.nil? 176 | @older_messages_query ||= db.prepare "SELECT id FROM message WHERE id NOT IN (SELECT id FROM message ORDER BY created_at DESC LIMIT ?)" 177 | @older_messages_query.execute(count).map do |row| 178 | Hash[row.fields.zip(row)] 179 | end.each do |message| 180 | delete_message!(message["id"]) 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /assets/stylesheets/mailcatcher.css.sass: -------------------------------------------------------------------------------- 1 | @import "compass" 2 | @import "compass/reset" 3 | 4 | html, body 5 | width: 100% 6 | height: 100% 7 | 8 | body 9 | +establish-baseline(12px) 10 | display: -moz-box 11 | display: -webkit-box 12 | display: box 13 | -moz-box-orient: vertical 14 | -webkit-box-orient: vertical 15 | box-orient: vertical 16 | background: #eee 17 | color: #000 18 | font-size: 12px 19 | font-family: Helvetica, sans-serif 20 | 21 | &.iframe 22 | background: #fff 23 | 24 | h1 25 | font-size: 1.3em 26 | margin: 12px 27 | p, form 28 | margin: 0 12px 12px 12px 29 | line-height: 1.25 30 | .loading 31 | color: #666 32 | margin-left: 0.5em 33 | 34 | .button 35 | padding: .5em 1em 36 | border: 1px solid #ccc 37 | +border-radius(2px) 38 | +background(linear-gradient(color-stops(#f4f4f4, #ececec)), #ececec) 39 | color: #666 40 | +text-shadow(1px 1px 0 #fff) 41 | text-decoration: none 42 | &:hover, &:focus 43 | border-color: #999 44 | border-bottom-color: #666 45 | +background(linear-gradient(color-stops(#eee, #ddd)), #ddd) 46 | color: #333 47 | text-decoration: none 48 | &:active, &.active 49 | border-color: #666 50 | border-bottom-color: #999 51 | +background(linear-gradient(color-stops(#ddd, #eee)), #eee) 52 | color: #333 53 | text-decoration: none 54 | +text-shadow(-1px -1px 0 #eee) 55 | 56 | body > header 57 | +clearfix 58 | border-bottom: 1px solid #ccc 59 | h1 60 | float: left 61 | margin-left: 6px 62 | padding: 6px 63 | padding-left: 30px 64 | background: url(logo.png) left no-repeat 65 | background-size: 24px 24px 66 | font-size: 18px 67 | font-weight: bold 68 | a 69 | color: black 70 | text-decoration: none 71 | +text-shadow(0 1px 0 white) 72 | +transition(0.1s ease) 73 | &:hover 74 | color: #4183C4 75 | nav 76 | &.project 77 | float: left 78 | &.app 79 | float: right 80 | border-left: 1px solid #ccc 81 | li 82 | display: block 83 | float: left 84 | border-left: 1px solid #fff 85 | border-right: 1px solid #ccc 86 | input 87 | margin: 6px 88 | a 89 | display: block 90 | padding: 10px 91 | text-decoration: none 92 | +text-shadow(0 1px 0 white) 93 | +background(linear-gradient(color-stops(#f4f4f4, #ececec)), #ececec) 94 | color: #666 95 | +text-shadow(1px 1px 0 #fff) 96 | text-decoration: none 97 | &:hover, &:focus 98 | +background(linear-gradient(color-stops(#eee, #ddd)), #ddd) 99 | color: #333 100 | text-decoration: none 101 | &:active, &.active 102 | +background(linear-gradient(color-stops(#ddd, #eee)), #eee) 103 | color: #333 104 | text-decoration: none 105 | +text-shadow(-1px -1px 0 #eee) 106 | 107 | #messages 108 | width: 100% 109 | height: 10em 110 | // Two rows with padding: 111 | min-height: (2 * (1em + .5em)) 112 | overflow: auto 113 | background: #fff 114 | border-top: 1px solid #fff 115 | table 116 | +clearfix 117 | width: 100% 118 | thead tr 119 | background: #eee 120 | color: #333 121 | th 122 | padding: .25em 123 | font-weight: bold 124 | color: #666 125 | +text-shadow(0 1px 0 white) 126 | tbody tr 127 | cursor: pointer 128 | +transition(0.1s ease) 129 | color: #333 130 | &:hover 131 | color: #000 132 | &:nth-child(even) 133 | background: #f0f0f0 134 | &.selected 135 | background: Highlight 136 | color: HighlightText 137 | td 138 | padding: .25em 139 | &.blank 140 | color: #666 141 | font-style: italic 142 | #resizer 143 | padding-bottom: 5px 144 | cursor: ns-resize 145 | .ruler 146 | border-top: 1px solid #ccc 147 | border-bottom: 1px solid #fff 148 | 149 | #message 150 | display: -moz-box 151 | display: -webkit-box 152 | display: box 153 | -moz-box-orient: vertical 154 | -webkit-box-orient: vertical 155 | box-orient: vertical 156 | -moz-box-flex: 1 157 | -webkit-box-flex: 1 158 | box-flex: 1 159 | > header 160 | +clearfix 161 | .metadata 162 | +clearfix 163 | padding: .5em 164 | // This is already padded by resizer 165 | padding-top: 0 166 | dt, dd 167 | padding: .25em 168 | dt 169 | float: left 170 | clear: left 171 | width: 8em 172 | margin-right: .5em 173 | text-align: right 174 | font-weight: bold 175 | color: #666 176 | +text-shadow(0 1px 0 white) 177 | dd.subject 178 | font-weight: bold 179 | .attachments 180 | display: none 181 | ul 182 | display: inline 183 | li 184 | +inline-block 185 | margin-right: .5em 186 | .views 187 | ul 188 | padding: 0 .5em 189 | border-bottom: 1px solid #ccc 190 | .tab 191 | +inline-block 192 | a 193 | +inline-block 194 | padding: .5em 195 | border: 1px solid #ccc 196 | background: #ddd 197 | color: #333 198 | border-width: 1px 1px 0 1px 199 | cursor: pointer 200 | +text-shadow(0 1px 0 #eeeeee) 201 | text-decoration: none 202 | &:not(.selected):hover a 203 | background-color: #eee 204 | &.selected a 205 | background: #fff 206 | color: #000 207 | height: 13px 208 | +box-shadow(1px 1px 0 #ccc) 209 | margin-bottom: -2px 210 | cursor: default 211 | .action 212 | +inline-block 213 | float: right 214 | margin: 0 .25em 215 | 216 | .fractal-analysis 217 | margin: 12px 0 218 | .report-intro 219 | font-weight: bold 220 | &.valid 221 | color: #090 222 | &.invalid 223 | color: #c33 224 | code 225 | font-family: Monaco, "Courier New", Courier, monospace 226 | background-color: #f8f8ff 227 | color: #444 228 | padding: 0 .2em 229 | border: 1px solid #dedede 230 | ul 231 | margin: 1em 0 1em 1em 232 | list-style-type: square 233 | ol 234 | margin: 1em 0 1em 2em 235 | list-style-type: decimal 236 | ul li, ol li 237 | display: list-item 238 | margin: .5em 0 .5em 1em 239 | .error-intro 240 | strong 241 | font-weight: bold 242 | .unsupported-clients 243 | dt 244 | padding-left: 1em 245 | dd 246 | padding-left: 2em 247 | ul 248 | li 249 | display: list-item 250 | 251 | iframe 252 | display: -moz-box 253 | display: -webkit-box 254 | display: box 255 | -moz-box-flex: 1 256 | -webkit-box-flex: 1 257 | box-flex: 1 258 | background: #fff 259 | 260 | @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) 261 | body > header 262 | h1 263 | background-image: url(logo_2x.png) 264 | 265 | #noscript-overlay 266 | position: absolute 267 | top: 0 268 | left: 0 269 | right: 0 270 | bottom: 0 271 | display: flex 272 | align-items: center 273 | justify-content: center 274 | background-color: rgba(0,0,0,.5) 275 | cursor: default 276 | outline: none 277 | 278 | #noscript 279 | display: block 280 | max-width: 100% 281 | margin: 2rem 282 | padding: 2rem 283 | border-radius: 0.5rem 284 | background-color: #fff 285 | box-shadow: 0 0 1rem 0 rgba(0,0,0,.4) 286 | -------------------------------------------------------------------------------- /lib/mail_catcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "open3" 4 | require "optparse" 5 | require "rbconfig" 6 | 7 | require "eventmachine" 8 | require "thin" 9 | 10 | module EventMachine 11 | # Monkey patch fix for 10deb4 12 | # See https://github.com/eventmachine/eventmachine/issues/569 13 | def self.reactor_running? 14 | (@reactor_running || false) 15 | end 16 | end 17 | 18 | require "mail_catcher/version" 19 | 20 | module MailCatcher extend self 21 | autoload :Bus, "mail_catcher/bus" 22 | autoload :Mail, "mail_catcher/mail" 23 | autoload :Smtp, "mail_catcher/smtp" 24 | autoload :Web, "mail_catcher/web" 25 | 26 | def env 27 | ENV.fetch("MAILCATCHER_ENV", "production") 28 | end 29 | 30 | def development? 31 | env == "development" 32 | end 33 | 34 | def which?(command) 35 | ENV["PATH"].split(File::PATH_SEPARATOR).any? do |directory| 36 | File.executable?(File.join(directory, command.to_s)) 37 | end 38 | end 39 | 40 | def windows? 41 | RbConfig::CONFIG["host_os"].match?(/mswin|mingw/) 42 | end 43 | 44 | def browsable? 45 | windows? or which? "open" 46 | end 47 | 48 | def browse url 49 | if windows? 50 | system "start", "/b", url 51 | elsif which? "open" 52 | system "open", url 53 | end 54 | end 55 | 56 | def log_exception(message, context, exception) 57 | gems_paths = (Gem.path | [Gem.default_dir]).map { |path| Regexp.escape(path) } 58 | gems_regexp = %r{(?:#{gems_paths.join("|")})/gems/([^/]+)-([\w.]+)/(.*)} 59 | gems_replace = '\1 (\2) \3' 60 | 61 | puts "*** #{message}: #{context.inspect}" 62 | puts " Exception: #{exception}" 63 | puts " Backtrace:", *exception.backtrace.map { |line| " #{line.sub(gems_regexp, gems_replace)}" } 64 | puts " Please submit this as an issue at https://github.com/sj26/mailcatcher/issues" 65 | end 66 | 67 | @@defaults = { 68 | :smtp_ip => "127.0.0.1", 69 | :smtp_port => "1025", 70 | :http_ip => "127.0.0.1", 71 | :http_port => "1080", 72 | :http_path => "/", 73 | :messages_limit => nil, 74 | :verbose => false, 75 | :daemon => !windows?, 76 | :browse => false, 77 | :quit => true, 78 | } 79 | 80 | def options 81 | @@options 82 | end 83 | 84 | def quittable? 85 | options[:quit] 86 | end 87 | 88 | def parse! arguments=ARGV, defaults=@defaults 89 | @@defaults.dup.tap do |options| 90 | OptionParser.new do |parser| 91 | parser.banner = "Usage: mailcatcher [options]" 92 | parser.version = VERSION 93 | parser.separator "" 94 | parser.separator "MailCatcher v#{VERSION}" 95 | parser.separator "" 96 | 97 | parser.on("--ip IP", "Set the ip address of both servers") do |ip| 98 | options[:smtp_ip] = options[:http_ip] = ip 99 | end 100 | 101 | parser.on("--smtp-ip IP", "Set the ip address of the smtp server") do |ip| 102 | options[:smtp_ip] = ip 103 | end 104 | 105 | parser.on("--smtp-port PORT", Integer, "Set the port of the smtp server") do |port| 106 | options[:smtp_port] = port 107 | end 108 | 109 | parser.on("--http-ip IP", "Set the ip address of the http server") do |ip| 110 | options[:http_ip] = ip 111 | end 112 | 113 | parser.on("--http-port PORT", Integer, "Set the port address of the http server") do |port| 114 | options[:http_port] = port 115 | end 116 | 117 | parser.on("--messages-limit COUNT", Integer, "Only keep up to COUNT most recent messages") do |count| 118 | options[:messages_limit] = count 119 | end 120 | 121 | parser.on("--http-path PATH", String, "Add a prefix to all HTTP paths") do |path| 122 | clean_path = Rack::Utils.clean_path_info("/#{path}") 123 | 124 | options[:http_path] = clean_path 125 | end 126 | 127 | parser.on("--no-quit", "Don't allow quitting the process") do 128 | options[:quit] = false 129 | end 130 | 131 | unless windows? 132 | parser.on("-f", "--foreground", "Run in the foreground") do 133 | options[:daemon] = false 134 | end 135 | end 136 | 137 | if browsable? 138 | parser.on("-b", "--browse", "Open web browser") do 139 | options[:browse] = true 140 | end 141 | end 142 | 143 | parser.on("-v", "--verbose", "Be more verbose") do 144 | options[:verbose] = true 145 | end 146 | 147 | parser.on_tail("-h", "--help", "Display this help information") do 148 | puts parser 149 | exit 150 | end 151 | 152 | parser.on_tail("--version", "Display the current version") do 153 | puts "MailCatcher v#{VERSION}" 154 | exit 155 | end 156 | end.parse! 157 | end 158 | end 159 | 160 | def run! options=nil 161 | # If we are passed options, fill in the blanks 162 | options &&= @@defaults.merge options 163 | # Otherwise, parse them from ARGV 164 | options ||= parse! 165 | 166 | # Stash them away for later 167 | @@options = options 168 | 169 | # If we're running in the foreground sync the output. 170 | unless options[:daemon] 171 | $stdout.sync = $stderr.sync = true 172 | end 173 | 174 | puts "Starting MailCatcher v#{VERSION}" 175 | 176 | Thin::Logging.debug = development? 177 | Thin::Logging.silent = !development? 178 | 179 | # One EventMachine loop... 180 | EventMachine.run do 181 | # Set up an SMTP server to run within EventMachine 182 | rescue_port options[:smtp_port] do 183 | EventMachine.start_server options[:smtp_ip], options[:smtp_port], Smtp 184 | puts "==> #{smtp_url}" 185 | end 186 | 187 | # Let Thin set itself up inside our EventMachine loop 188 | # Faye connections are hijacked but continue to be supervised by thin 189 | rescue_port options[:http_port] do 190 | Thin::Server.start(options[:http_ip], options[:http_port], Web, signals: false) 191 | puts "==> #{http_url}" 192 | end 193 | 194 | # Make sure we quit nicely when asked 195 | # We need to handle outside the trap context, hence the timer 196 | trap("INT") { EM.add_timer(0) { quit! } } 197 | trap("TERM") { EM.add_timer(0) { quit! } } 198 | trap("QUIT") { EM.add_timer(0) { quit! } } unless windows? 199 | 200 | # Open the web browser before detaching console 201 | if options[:browse] 202 | EventMachine.next_tick do 203 | browse http_url 204 | end 205 | end 206 | 207 | # Daemonize, if we should, but only after the servers have started. 208 | if options[:daemon] 209 | EventMachine.next_tick do 210 | if quittable? 211 | puts "*** MailCatcher runs as a daemon by default. Go to the web interface to quit." 212 | else 213 | puts "*** MailCatcher is now running as a daemon that cannot be quit." 214 | end 215 | Process.daemon 216 | end 217 | end 218 | end 219 | end 220 | 221 | def quit! 222 | MailCatcher::Bus.push(type: "quit") 223 | 224 | EventMachine.next_tick { EventMachine.stop_event_loop } 225 | end 226 | 227 | protected 228 | 229 | def smtp_url 230 | "smtp://#{@@options[:smtp_ip]}:#{@@options[:smtp_port]}" 231 | end 232 | 233 | def http_url 234 | "http://#{@@options[:http_ip]}:#{@@options[:http_port]}#{@@options[:http_path]}".chomp("/") 235 | end 236 | 237 | def rescue_port port 238 | begin 239 | yield 240 | 241 | # XXX: EventMachine only spits out RuntimeError with a string description 242 | rescue RuntimeError 243 | if $!.to_s =~ /\bno acceptor\b/ 244 | puts "~~> ERROR: Something's using port #{port}. Are you already running MailCatcher?" 245 | puts "==> #{smtp_url}" 246 | puts "==> #{http_url}" 247 | exit -1 248 | else 249 | raise 250 | end 251 | end 252 | end 253 | end 254 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MailCatcher 2 | 3 | Catches mail and serves it through a dream. 4 | 5 | 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. 6 | 7 | ![MailCatcher screenshot](https://cloud.githubusercontent.com/assets/14028/14093249/4100f904-f598-11e5-936b-e6a396f18e39.png) 8 | 9 | ## Features 10 | 11 | * Catches all mail and stores it for display. 12 | * Shows HTML, Plain Text and Source version of messages, as applicable. 13 | * Rewrites HTML enabling display of embedded, inline images/etc and opens links in a new window. 14 | * Lists attachments and allows separate downloading of parts. 15 | * Download original email to view in your native mail client(s). 16 | * Command line options to override the default SMTP/HTTP IP and port settings. 17 | * Mail appears instantly if your browser supports [WebSockets][websockets], otherwise updates every thirty seconds. 18 | * Runs as a daemon in the background, optionally in foreground. 19 | * Sendmail-analogue command, `catchmail`, makes using mailcatcher from PHP a lot easier. 20 | * Keyboard navigation between messages 21 | 22 | ## How 23 | 24 | 1. `gem install mailcatcher` 25 | 2. `mailcatcher` 26 | 3. Go to http://127.0.0.1:1080/ 27 | 4. Send mail through smtp://127.0.0.1:1025 28 | 29 | ### Command Line Options 30 | 31 | Use `mailcatcher --help` to see the command line options. 32 | 33 | ``` 34 | Usage: mailcatcher [options] 35 | 36 | MailCatcher v0.8.0 37 | 38 | --ip IP Set the ip address of both servers 39 | --smtp-ip IP Set the ip address of the smtp server 40 | --smtp-port PORT Set the port of the smtp server 41 | --http-ip IP Set the ip address of the http server 42 | --http-port PORT Set the port address of the http server 43 | --messages-limit COUNT Only keep up to COUNT most recent messages 44 | --http-path PATH Add a prefix to all HTTP paths 45 | --no-quit Don't allow quitting the process 46 | -f, --foreground Run in the foreground 47 | -b, --browse Open web browser 48 | -v, --verbose Be more verbose 49 | -h, --help Display this help information 50 | --version Display the current version 51 | ``` 52 | 53 | ### Upgrading 54 | 55 | Upgrading works the same as installation: 56 | 57 | ``` 58 | gem install mailcatcher 59 | ``` 60 | 61 | ### Ruby 62 | 63 | If you have trouble with the setup commands, make sure you have [Ruby installed](https://www.ruby-lang.org/en/documentation/installation/): 64 | 65 | ``` 66 | ruby -v 67 | gem environment 68 | ``` 69 | 70 | You might need to install build tools for some of the gem dependencies. On Debian or Ubuntu, `apt install build-essential`. On macOS, `xcode-select --install`. 71 | 72 | If you encounter issues installing [thin](https://rubygems.org/gems/thin), try: 73 | 74 | ``` 75 | gem install thin -v 1.5.1 -- --with-cflags="-Wno-error=implicit-function-declaration" 76 | ``` 77 | 78 | ### Bundler 79 | 80 | Please don't put mailcatcher into your Gemfile. It will conflict with your application's gems at some point. 81 | 82 | Instead, pop a note in your README stating you use mailcatcher, and to run `gem install mailcatcher` then `mailcatcher` to get started. 83 | 84 | ### RVM 85 | 86 | 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 with a wrapper script: 87 | 88 | rvm default@mailcatcher --create do gem install mailcatcher 89 | ln -s "$(rvm default@mailcatcher do rvm wrapper show mailcatcher)" "$rvm_bin_path/" 90 | 91 | ### Rails 92 | 93 | To set up your rails app, I recommend adding this to your `environments/development.rb`: 94 | 95 | config.action_mailer.delivery_method = :smtp 96 | config.action_mailer.smtp_settings = { :address => '127.0.0.1', :port => 1025 } 97 | config.action_mailer.raise_delivery_errors = false 98 | 99 | ### PHP 100 | 101 | For projects using PHP, or PHP frameworks and application platforms like Drupal, you can set [PHP's mail configuration](https://www.php.net/manual/en/mail.configuration.php) in your [php.ini](https://www.php.net/manual/en/configuration.file.php) to send via MailCatcher with: 102 | 103 | sendmail_path = /usr/bin/env catchmail -f some@from.address 104 | 105 | You can do this in your [Apache configuration](https://www.php.net/manual/en/configuration.changes.php) like so: 106 | 107 | php_admin_value sendmail_path "/usr/bin/env catchmail -f some@from.address" 108 | 109 | 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`. 110 | 111 | 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: 112 | 113 | sendmail_path = /usr/bin/env catchmail --smtp-ip 192.160.0.1 --smtp-port 10025 -f some@from.address 114 | 115 | ### Django 116 | 117 | For use in Django, add the following configuration to your projects' settings.py 118 | 119 | ```python 120 | if DEBUG: 121 | EMAIL_HOST = '127.0.0.1' 122 | EMAIL_HOST_USER = '' 123 | EMAIL_HOST_PASSWORD = '' 124 | EMAIL_PORT = 1025 125 | EMAIL_USE_TLS = False 126 | ``` 127 | 128 | ### Docker 129 | 130 | There is a Docker image available [on Docker Hub](https://hub.docker.com/r/sj26/mailcatcher): 131 | 132 | ``` 133 | $ docker run -p 1080 -p 1025 sj26/mailcatcher 134 | Unable to find image 'sj26/mailcatcher:latest' locally 135 | latest: Pulling from sj26/mailcatcher 136 | 8c6d1654570f: Already exists 137 | f5649d186f41: Already exists 138 | b850834ea1df: Already exists 139 | d6ac1a07fd46: Pull complete 140 | b609298bc3c9: Pull complete 141 | ab05825ece51: Pull complete 142 | Digest: sha256:b17c45de08a0a82b012d90d4bd048620952c475f5655c61eef373318de6c0855 143 | Status: Downloaded newer image for sj26/mailcatcher:latest 144 | Starting MailCatcher v0.9.0 145 | ==> smtp://0.0.0.0:1025 146 | ==> http://0.0.0.0:1080 147 | ``` 148 | 149 | How those ports appear and can be accessed may vary based on your Docker configuration. For example, your may need to use `http://127.0.0.1:1080` etc instead of the listed address. But MailCatcher will run and listen to those ports on all IPs it can from within the Docker container. 150 | 151 | ### API 152 | 153 | 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/parts/:cid` for individual attachments by CID, or the whole message with `/messages/:id.source`. 154 | 155 | ## Caveats 156 | 157 | * 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. 158 | * Encodings are difficult. MailCatcher does not completely support utf-8 straight over the wire, you must use a mail library which encodes things properly based on SMTP server capabilities. 159 | 160 | ## Thanks 161 | 162 | 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. 163 | 164 | ## Donations 165 | 166 | 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). 167 | 168 | ## License 169 | 170 | Copyright © 2010-2019 Samuel Cochran (sj26@sj26.com). Released under the MIT License, see [LICENSE][license] for details. 171 | 172 | [donate]: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=522WUPLRWUSKE 173 | [license]: https://github.com/sj26/mailcatcher/blob/master/LICENSE 174 | [mailcatcher-github]: https://github.com/sj26/mailcatcher 175 | [mailcatcher-issues]: https://github.com/sj26/mailcatcher/issues 176 | [websockets]: https://tools.ietf.org/html/rfc6455 177 | -------------------------------------------------------------------------------- /spec/delivery_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe MailCatcher, type: :feature do 6 | def messages_element 7 | page.find("#messages") 8 | end 9 | 10 | def message_row_element 11 | messages_element.find(:xpath, ".//table/tbody/tr[1]") 12 | end 13 | 14 | def message_from_element 15 | message_row_element.find(:xpath, ".//td[1]") 16 | end 17 | 18 | def message_to_element 19 | message_row_element.find(:xpath, ".//td[2]") 20 | end 21 | 22 | def message_subject_element 23 | message_row_element.find(:xpath, ".//td[3]") 24 | end 25 | 26 | def message_received_element 27 | message_row_element.find(:xpath, ".//td[4]") 28 | end 29 | 30 | def html_tab_element 31 | page.find("#message header .format.html a") 32 | end 33 | 34 | def plain_tab_element 35 | page.find("#message header .format.plain a") 36 | end 37 | 38 | def source_tab_element 39 | page.find("#message header .format.source a") 40 | end 41 | 42 | def attachment_header_element 43 | page.find("#message header .metadata dt.attachments") 44 | end 45 | 46 | def attachment_contents_element 47 | page.find("#message header .metadata dd.attachments") 48 | end 49 | 50 | def first_attachment_element 51 | attachment_contents_element.find("ul li:first-of-type a") 52 | end 53 | 54 | def body_element 55 | page.find("body") 56 | end 57 | 58 | it "catches and displays a plain text message as plain text and source" do 59 | deliver_example("plainmail") 60 | 61 | # Do not reload, make sure that the message appears via websockets 62 | 63 | expect(page).to have_selector("#messages table tbody tr:first-of-type", text: "Plain mail") 64 | 65 | expect(message_from_element).to have_text(DEFAULT_FROM) 66 | expect(message_to_element).to have_text(DEFAULT_TO) 67 | expect(message_subject_element).to have_text("Plain mail") 68 | expect(Time.parse(message_received_element.text)).to be <= Time.now + 5 69 | 70 | message_row_element.click 71 | 72 | expect(source_tab_element).to be_visible 73 | expect(plain_tab_element).to be_visible 74 | expect(page).to have_no_selector("#message header .format.html a") 75 | 76 | plain_tab_element.click 77 | 78 | within_frame do 79 | expect(body_element).to have_no_text("Subject: Plain mail") 80 | expect(body_element).to have_text("Here's some text") 81 | end 82 | 83 | source_tab_element.click 84 | 85 | within_frame do 86 | expect(body_element.text).to include("Subject: Plain mail") 87 | expect(body_element.text).to include("Here's some text") 88 | end 89 | end 90 | 91 | it "catches and displays an html message as html and source" do 92 | deliver_example("htmlmail") 93 | 94 | # Do not reload, make sure that the message appears via websockets 95 | 96 | expect(page).to have_selector("#messages table tbody tr:first-of-type", text: "Test HTML Mail") 97 | 98 | expect(message_from_element).to have_text(DEFAULT_FROM) 99 | expect(message_to_element).to have_text(DEFAULT_TO) 100 | expect(message_subject_element).to have_text("Test HTML Mail") 101 | expect(Time.parse(message_received_element.text)).to be <= Time.now + 5 102 | 103 | message_row_element.click 104 | 105 | expect(source_tab_element).to be_visible 106 | expect(page).to have_no_selector("#message header .format.plain a") 107 | expect(html_tab_element).to be_visible 108 | 109 | html_tab_element.click 110 | 111 | within_frame do 112 | expect(page).to have_text("Yo, you slimey scoundrel.") 113 | expect(page).to have_no_text("Content-Type: text/html") 114 | expect(page).to have_no_text("Yo, you slimey scoundrel.") 115 | end 116 | 117 | source_tab_element.click 118 | 119 | within_frame do 120 | expect(page).to have_no_text("Yo, you slimey scoundrel.") 121 | expect(page).to have_text("Content-Type: text/html") 122 | expect(page).to have_text("Yo, you slimey scoundrel.") 123 | end 124 | end 125 | 126 | it "catches and displays a multipart message as text, html and source" do 127 | deliver_example("multipartmail") 128 | 129 | # Do not reload, make sure that the message appears via websockets 130 | 131 | expect(page).to have_selector("#messages table tbody tr:first-of-type", text: "Test Multipart Mail") 132 | 133 | expect(message_from_element).to have_text(DEFAULT_FROM) 134 | expect(message_to_element).to have_text(DEFAULT_TO) 135 | expect(message_subject_element).to have_text("Test Multipart Mail") 136 | expect(Time.parse(message_received_element.text)).to be <= Time.now + 5 137 | 138 | message_row_element.click 139 | 140 | expect(source_tab_element).to be_visible 141 | expect(plain_tab_element).to be_visible 142 | expect(html_tab_element).to be_visible 143 | 144 | plain_tab_element.click 145 | 146 | within_frame do 147 | expect(page).to have_text "Plain text mail" 148 | expect(page).to have_no_text "HTML mail" 149 | expect(page).to have_no_text "Content-Type: multipart/alternative; boundary=BOUNDARY--198849662" 150 | end 151 | 152 | html_tab_element.click 153 | 154 | within_frame do 155 | expect(page).to have_no_text "Plain text mail" 156 | expect(page).to have_text "HTML mail" 157 | expect(page).to have_no_text "Content-Type: multipart/alternative; boundary=BOUNDARY--198849662" 158 | end 159 | 160 | source_tab_element.click 161 | 162 | within_frame do 163 | expect(page).to have_text "Content-Type: multipart/alternative; boundary=BOUNDARY--198849662" 164 | expect(page).to have_text "Plain text mail" 165 | expect(page).to have_text "HTML mail" 166 | end 167 | end 168 | 169 | it "catches and displays a multipart UTF8 message as text, html and source" do 170 | deliver_example("multipartmail-with-utf8") 171 | 172 | # Do not reload, make sure that the message appears via websockets 173 | 174 | expect(page).to have_selector("#messages table tbody tr:first-of-type", text: "Test Multipart UTF8 Mail") 175 | 176 | expect(message_from_element).to have_text(DEFAULT_FROM) 177 | expect(message_to_element).to have_text(DEFAULT_TO) 178 | expect(message_subject_element).to have_text("Test Multipart UTF8 Mail") 179 | expect(Time.parse(message_received_element.text)).to be <= Time.now + 5 180 | 181 | message_row_element.click 182 | 183 | expect(source_tab_element).to be_visible 184 | expect(plain_tab_element).to be_visible 185 | expect(html_tab_element).to be_visible 186 | 187 | plain_tab_element.click 188 | 189 | within_frame do 190 | expect(page).to have_text "Plain text mail" 191 | expect(page).to have_no_text "© HTML mail" 192 | expect(page).to have_no_text "Content-Type: multipart/alternative; boundary=BOUNDARY--198849662" 193 | end 194 | 195 | html_tab_element.click 196 | 197 | within_frame do 198 | expect(page).to have_no_text "Plain text mail" 199 | expect(page).to have_text "© HTML mail" 200 | expect(page).to have_no_text "Content-Type: multipart/alternative; boundary=BOUNDARY--198849662" 201 | end 202 | 203 | source_tab_element.click 204 | 205 | within_frame do 206 | expect(page).to have_text "Content-Type: multipart/alternative; boundary=BOUNDARY--198849662" 207 | expect(page).to have_text "Plain text mail" 208 | expect(page).to have_text "© HTML mail" 209 | end 210 | end 211 | 212 | it "catches and displays an unknown message as source" do 213 | deliver_example("unknownmail") 214 | 215 | # Do not reload, make sure that the message appears via websockets 216 | 217 | skip 218 | end 219 | 220 | it "catches and displays a message with multipart attachments" do 221 | deliver_example("attachmail") 222 | 223 | # Do not reload, make sure that the message appears via websockets 224 | 225 | expect(page).to have_selector("#messages table tbody tr:first-of-type", text: "Test Attachment Mail") 226 | 227 | expect(message_from_element).to have_text(DEFAULT_FROM) 228 | expect(message_to_element).to have_text(DEFAULT_TO) 229 | expect(message_subject_element).to have_text("Test Attachment Mail") 230 | expect(Time.parse(message_received_element.text)).to be <= Time.now + 5 231 | 232 | message_row_element.click 233 | 234 | expect(source_tab_element).to be_visible 235 | expect(plain_tab_element).to be_visible 236 | expect(attachment_header_element).to be_visible 237 | 238 | plain_tab_element.click 239 | 240 | within_frame do 241 | expect(page).to have_text "This is plain text" 242 | end 243 | 244 | expect(first_attachment_element).to be_visible 245 | expect(first_attachment_element).to have_text("attachment") 246 | 247 | # Downloading via the browser is hard, so just grab from the URI directly 248 | expect(Net::HTTP.get(URI.join(Capybara.app_host, first_attachment_element[:href]))).to eql("Hello, I am an attachment!\r\n") 249 | 250 | source_tab_element.click 251 | 252 | within_frame do 253 | expect(page).to have_text "Content-Type: multipart/mixed" 254 | expect(page).to have_text "This is plain text" 255 | 256 | expect(page).to have_text "Content-Disposition: attachment" 257 | # Too hard to add expectations on the transfer encoded attachment contents 258 | end 259 | end 260 | 261 | it "doesn't choke on messages containing dots" do 262 | deliver_example("dotmail") 263 | 264 | # Do not reload, make sure that the message appears via websockets 265 | 266 | skip 267 | end 268 | 269 | it "doesn't choke on messages containing quoted printables" do 270 | deliver_example("quoted_printable_htmlmail") 271 | 272 | # Do not reload, make sure that the message appears via websockets 273 | 274 | skip 275 | end 276 | end 277 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/keymaster.js: -------------------------------------------------------------------------------- 1 | // keymaster.js 2 | // (c) 2011-2013 Thomas Fuchs 3 | // keymaster.js may be freely distributed under the MIT license. 4 | 5 | ;(function(global){ 6 | var k, 7 | _handlers = {}, 8 | _mods = { 16: false, 18: false, 17: false, 91: false }, 9 | _scope = 'all', 10 | // modifier keys 11 | _MODIFIERS = { 12 | '⇧': 16, shift: 16, 13 | '⌥': 18, alt: 18, option: 18, 14 | '⌃': 17, ctrl: 17, control: 17, 15 | '⌘': 91, command: 91 16 | }, 17 | // special keys 18 | _MAP = { 19 | backspace: 8, tab: 9, clear: 12, 20 | enter: 13, 'return': 13, 21 | esc: 27, escape: 27, space: 32, 22 | left: 37, up: 38, 23 | right: 39, down: 40, 24 | del: 46, 'delete': 46, 25 | home: 36, end: 35, 26 | pageup: 33, pagedown: 34, 27 | ',': 188, '.': 190, '/': 191, 28 | '`': 192, '-': 189, '=': 187, 29 | ';': 186, '\'': 222, 30 | '[': 219, ']': 221, '\\': 220 31 | }, 32 | code = function(x){ 33 | return _MAP[x] || x.toUpperCase().charCodeAt(0); 34 | }, 35 | _downKeys = []; 36 | 37 | for(k=1;k<20;k++) _MAP['f'+k] = 111+k; 38 | 39 | // IE doesn't support Array#indexOf, so have a simple replacement 40 | function index(array, item){ 41 | var i = array.length; 42 | while(i--) if(array[i]===item) return i; 43 | return -1; 44 | } 45 | 46 | // for comparing mods before unassignment 47 | function compareArray(a1, a2) { 48 | if (a1.length != a2.length) return false; 49 | for (var i = 0; i < a1.length; i++) { 50 | if (a1[i] !== a2[i]) return false; 51 | } 52 | return true; 53 | } 54 | 55 | var modifierMap = { 56 | 16:'shiftKey', 57 | 18:'altKey', 58 | 17:'ctrlKey', 59 | 91:'metaKey' 60 | }; 61 | function updateModifierKey(event) { 62 | for(k in _mods) _mods[k] = event[modifierMap[k]]; 63 | }; 64 | 65 | // handle keydown event 66 | function dispatch(event) { 67 | var key, handler, k, i, modifiersMatch, scope; 68 | key = event.keyCode; 69 | 70 | if (index(_downKeys, key) == -1) { 71 | _downKeys.push(key); 72 | } 73 | 74 | // if a modifier key, set the key. property to true and return 75 | if(key == 93 || key == 224) key = 91; // right command on webkit, command on Gecko 76 | if(key in _mods) { 77 | _mods[key] = true; 78 | // 'assignKey' from inside this closure is exported to window.key 79 | for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = true; 80 | return; 81 | } 82 | updateModifierKey(event); 83 | 84 | // see if we need to ignore the keypress (filter() can can be overridden) 85 | // by default ignore key presses if a select, textarea, or input is focused 86 | if(!assignKey.filter.call(this, event)) return; 87 | 88 | // abort if no potentially matching shortcuts found 89 | if (!(key in _handlers)) return; 90 | 91 | scope = getScope(); 92 | 93 | // for each potential shortcut 94 | for (i = 0; i < _handlers[key].length; i++) { 95 | handler = _handlers[key][i]; 96 | 97 | // see if it's in the current scope 98 | if(handler.scope == scope || handler.scope == 'all'){ 99 | // check if modifiers match if any 100 | modifiersMatch = handler.mods.length > 0; 101 | for(k in _mods) 102 | if((!_mods[k] && index(handler.mods, +k) > -1) || 103 | (_mods[k] && index(handler.mods, +k) == -1)) modifiersMatch = false; 104 | // call the handler and stop the event if neccessary 105 | if((handler.mods.length == 0 && !_mods[16] && !_mods[18] && !_mods[17] && !_mods[91]) || modifiersMatch){ 106 | if(handler.method(event, handler)===false){ 107 | if(event.preventDefault) event.preventDefault(); 108 | else event.returnValue = false; 109 | if(event.stopPropagation) event.stopPropagation(); 110 | if(event.cancelBubble) event.cancelBubble = true; 111 | } 112 | } 113 | } 114 | } 115 | }; 116 | 117 | // unset modifier keys on keyup 118 | function clearModifier(event){ 119 | var key = event.keyCode, k, 120 | i = index(_downKeys, key); 121 | 122 | // remove key from _downKeys 123 | if (i >= 0) { 124 | _downKeys.splice(i, 1); 125 | } 126 | 127 | if(key == 93 || key == 224) key = 91; 128 | if(key in _mods) { 129 | _mods[key] = false; 130 | for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = false; 131 | } 132 | }; 133 | 134 | function resetModifiers() { 135 | for(k in _mods) _mods[k] = false; 136 | for(k in _MODIFIERS) assignKey[k] = false; 137 | }; 138 | 139 | // parse and assign shortcut 140 | function assignKey(key, scope, method){ 141 | var keys, mods; 142 | keys = getKeys(key); 143 | if (method === undefined) { 144 | method = scope; 145 | scope = 'all'; 146 | } 147 | 148 | // for each shortcut 149 | for (var i = 0; i < keys.length; i++) { 150 | // set modifier keys if any 151 | mods = []; 152 | key = keys[i].split('+'); 153 | if (key.length > 1){ 154 | mods = getMods(key); 155 | key = [key[key.length-1]]; 156 | } 157 | // convert to keycode and... 158 | key = key[0] 159 | key = code(key); 160 | // ...store handler 161 | if (!(key in _handlers)) _handlers[key] = []; 162 | _handlers[key].push({ shortcut: keys[i], scope: scope, method: method, key: keys[i], mods: mods }); 163 | } 164 | }; 165 | 166 | // unbind all handlers for given key in current scope 167 | function unbindKey(key, scope) { 168 | var multipleKeys, keys, 169 | mods = [], 170 | i, j, obj; 171 | 172 | multipleKeys = getKeys(key); 173 | 174 | for (j = 0; j < multipleKeys.length; j++) { 175 | keys = multipleKeys[j].split('+'); 176 | 177 | if (keys.length > 1) { 178 | mods = getMods(keys); 179 | key = keys[keys.length - 1]; 180 | } 181 | 182 | key = code(key); 183 | 184 | if (scope === undefined) { 185 | scope = getScope(); 186 | } 187 | if (!_handlers[key]) { 188 | return; 189 | } 190 | for (i = 0; i < _handlers[key].length; i++) { 191 | obj = _handlers[key][i]; 192 | // only clear handlers if correct scope and mods match 193 | if (obj.scope === scope && compareArray(obj.mods, mods)) { 194 | _handlers[key][i] = {}; 195 | } 196 | } 197 | } 198 | }; 199 | 200 | // Returns true if the key with code 'keyCode' is currently down 201 | // Converts strings into key codes. 202 | function isPressed(keyCode) { 203 | if (typeof(keyCode)=='string') { 204 | keyCode = code(keyCode); 205 | } 206 | return index(_downKeys, keyCode) != -1; 207 | } 208 | 209 | function getPressedKeyCodes() { 210 | return _downKeys.slice(0); 211 | } 212 | 213 | function filter(event){ 214 | var tagName = (event.target || event.srcElement).tagName; 215 | // ignore keypressed in any elements that support keyboard data input 216 | return !(tagName == 'INPUT' || tagName == 'SELECT' || tagName == 'TEXTAREA'); 217 | } 218 | 219 | // initialize key. to false 220 | for(k in _MODIFIERS) assignKey[k] = false; 221 | 222 | // set current scope (default 'all') 223 | function setScope(scope){ _scope = scope || 'all' }; 224 | function getScope(){ return _scope || 'all' }; 225 | 226 | // delete all handlers for a given scope 227 | function deleteScope(scope){ 228 | var key, handlers, i; 229 | 230 | for (key in _handlers) { 231 | handlers = _handlers[key]; 232 | for (i = 0; i < handlers.length; ) { 233 | if (handlers[i].scope === scope) handlers.splice(i, 1); 234 | else i++; 235 | } 236 | } 237 | }; 238 | 239 | // abstract key logic for assign and unassign 240 | function getKeys(key) { 241 | var keys; 242 | key = key.replace(/\s/g, ''); 243 | keys = key.split(','); 244 | if ((keys[keys.length - 1]) == '') { 245 | keys[keys.length - 2] += ','; 246 | } 247 | return keys; 248 | } 249 | 250 | // abstract mods logic for assign and unassign 251 | function getMods(key) { 252 | var mods = key.slice(0, key.length - 1); 253 | for (var mi = 0; mi < mods.length; mi++) 254 | mods[mi] = _MODIFIERS[mods[mi]]; 255 | return mods; 256 | } 257 | 258 | // cross-browser events 259 | function addEvent(object, event, method) { 260 | if (object.addEventListener) 261 | object.addEventListener(event, method, false); 262 | else if(object.attachEvent) 263 | object.attachEvent('on'+event, function(){ method(window.event) }); 264 | }; 265 | 266 | // set the handlers globally on document 267 | addEvent(document, 'keydown', function(event) { dispatch(event) }); // Passing _scope to a callback to ensure it remains the same by execution. Fixes #48 268 | addEvent(document, 'keyup', clearModifier); 269 | 270 | // reset modifiers to false whenever the window is (re)focused. 271 | addEvent(window, 'focus', resetModifiers); 272 | 273 | // store previously defined key 274 | var previousKey = global.key; 275 | 276 | // restore previously defined key and return reference to our key object 277 | function noConflict() { 278 | var k = global.key; 279 | global.key = previousKey; 280 | return k; 281 | } 282 | 283 | // set window.key and window.key.set/get/deleteScope, and the default filter 284 | global.key = assignKey; 285 | global.key.setScope = setScope; 286 | global.key.getScope = getScope; 287 | global.key.deleteScope = deleteScope; 288 | global.key.filter = filter; 289 | global.key.isPressed = isPressed; 290 | global.key.getPressedKeyCodes = getPressedKeyCodes; 291 | global.key.noConflict = noConflict; 292 | global.key.unbind = unbindKey; 293 | 294 | if(typeof module !== 'undefined') module.exports = key; 295 | 296 | })(this); 297 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/modernizr.js: -------------------------------------------------------------------------------- 1 | /* Modernizr 2.7.1 (Custom Build) | MIT & BSD 2 | * Build: http://modernizr.com/download/#-shiv-cssclasses 3 | */ 4 | ; 5 | 6 | 7 | 8 | window.Modernizr = (function( window, document, undefined ) { 9 | 10 | var version = '2.7.1', 11 | 12 | Modernizr = {}, 13 | 14 | enableClasses = true, 15 | 16 | docElement = document.documentElement, 17 | 18 | mod = 'modernizr', 19 | modElem = document.createElement(mod), 20 | mStyle = modElem.style, 21 | 22 | inputElem , 23 | 24 | 25 | toString = {}.toString, tests = {}, 26 | inputs = {}, 27 | attrs = {}, 28 | 29 | classes = [], 30 | 31 | slice = classes.slice, 32 | 33 | featureName, 34 | 35 | 36 | 37 | _hasOwnProperty = ({}).hasOwnProperty, hasOwnProp; 38 | 39 | if ( !is(_hasOwnProperty, 'undefined') && !is(_hasOwnProperty.call, 'undefined') ) { 40 | hasOwnProp = function (object, property) { 41 | return _hasOwnProperty.call(object, property); 42 | }; 43 | } 44 | else { 45 | hasOwnProp = function (object, property) { 46 | return ((property in object) && is(object.constructor.prototype[property], 'undefined')); 47 | }; 48 | } 49 | 50 | 51 | if (!Function.prototype.bind) { 52 | Function.prototype.bind = function bind(that) { 53 | 54 | var target = this; 55 | 56 | if (typeof target != "function") { 57 | throw new TypeError(); 58 | } 59 | 60 | var args = slice.call(arguments, 1), 61 | bound = function () { 62 | 63 | if (this instanceof bound) { 64 | 65 | var F = function(){}; 66 | F.prototype = target.prototype; 67 | var self = new F(); 68 | 69 | var result = target.apply( 70 | self, 71 | args.concat(slice.call(arguments)) 72 | ); 73 | if (Object(result) === result) { 74 | return result; 75 | } 76 | return self; 77 | 78 | } else { 79 | 80 | return target.apply( 81 | that, 82 | args.concat(slice.call(arguments)) 83 | ); 84 | 85 | } 86 | 87 | }; 88 | 89 | return bound; 90 | }; 91 | } 92 | 93 | function setCss( str ) { 94 | mStyle.cssText = str; 95 | } 96 | 97 | function setCssAll( str1, str2 ) { 98 | return setCss(prefixes.join(str1 + ';') + ( str2 || '' )); 99 | } 100 | 101 | function is( obj, type ) { 102 | return typeof obj === type; 103 | } 104 | 105 | function contains( str, substr ) { 106 | return !!~('' + str).indexOf(substr); 107 | } 108 | 109 | 110 | function testDOMProps( props, obj, elem ) { 111 | for ( var i in props ) { 112 | var item = obj[props[i]]; 113 | if ( item !== undefined) { 114 | 115 | if (elem === false) return props[i]; 116 | 117 | if (is(item, 'function')){ 118 | return item.bind(elem || obj); 119 | } 120 | 121 | return item; 122 | } 123 | } 124 | return false; 125 | } 126 | for ( var feature in tests ) { 127 | if ( hasOwnProp(tests, feature) ) { 128 | featureName = feature.toLowerCase(); 129 | Modernizr[featureName] = tests[feature](); 130 | 131 | classes.push((Modernizr[featureName] ? '' : 'no-') + featureName); 132 | } 133 | } 134 | 135 | 136 | 137 | Modernizr.addTest = function ( feature, test ) { 138 | if ( typeof feature == 'object' ) { 139 | for ( var key in feature ) { 140 | if ( hasOwnProp( feature, key ) ) { 141 | Modernizr.addTest( key, feature[ key ] ); 142 | } 143 | } 144 | } else { 145 | 146 | feature = feature.toLowerCase(); 147 | 148 | if ( Modernizr[feature] !== undefined ) { 149 | return Modernizr; 150 | } 151 | 152 | test = typeof test == 'function' ? test() : test; 153 | 154 | if (typeof enableClasses !== "undefined" && enableClasses) { 155 | docElement.className += ' ' + (test ? '' : 'no-') + feature; 156 | } 157 | Modernizr[feature] = test; 158 | 159 | } 160 | 161 | return Modernizr; 162 | }; 163 | 164 | 165 | setCss(''); 166 | modElem = inputElem = null; 167 | 168 | ;(function(window, document) { 169 | var version = '3.7.0'; 170 | 171 | var options = window.html5 || {}; 172 | 173 | var reSkip = /^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i; 174 | 175 | 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; 176 | 177 | var supportsHtml5Styles; 178 | 179 | var expando = '_html5shiv'; 180 | 181 | var expanID = 0; 182 | 183 | var expandoData = {}; 184 | 185 | var supportsUnknownElements; 186 | 187 | (function() { 188 | try { 189 | var a = document.createElement('a'); 190 | a.innerHTML = ''; 191 | supportsHtml5Styles = ('hidden' in a); 192 | 193 | supportsUnknownElements = a.childNodes.length == 1 || (function() { 194 | (document.createElement)('a'); 195 | var frag = document.createDocumentFragment(); 196 | return ( 197 | typeof frag.cloneNode == 'undefined' || 198 | typeof frag.createDocumentFragment == 'undefined' || 199 | typeof frag.createElement == 'undefined' 200 | ); 201 | }()); 202 | } catch(e) { 203 | supportsHtml5Styles = true; 204 | supportsUnknownElements = true; 205 | } 206 | 207 | }()); 208 | 209 | function addStyleSheet(ownerDocument, cssText) { 210 | var p = ownerDocument.createElement('p'), 211 | parent = ownerDocument.getElementsByTagName('head')[0] || ownerDocument.documentElement; 212 | 213 | p.innerHTML = 'x'; 214 | return parent.insertBefore(p.lastChild, parent.firstChild); 215 | } 216 | 217 | function getElements() { 218 | var elements = html5.elements; 219 | return typeof elements == 'string' ? elements.split(' ') : elements; 220 | } 221 | 222 | function getExpandoData(ownerDocument) { 223 | var data = expandoData[ownerDocument[expando]]; 224 | if (!data) { 225 | data = {}; 226 | expanID++; 227 | ownerDocument[expando] = expanID; 228 | expandoData[expanID] = data; 229 | } 230 | return data; 231 | } 232 | 233 | function createElement(nodeName, ownerDocument, data){ 234 | if (!ownerDocument) { 235 | ownerDocument = document; 236 | } 237 | if(supportsUnknownElements){ 238 | return ownerDocument.createElement(nodeName); 239 | } 240 | if (!data) { 241 | data = getExpandoData(ownerDocument); 242 | } 243 | var node; 244 | 245 | if (data.cache[nodeName]) { 246 | node = data.cache[nodeName].cloneNode(); 247 | } else if (saveClones.test(nodeName)) { 248 | node = (data.cache[nodeName] = data.createElem(nodeName)).cloneNode(); 249 | } else { 250 | node = data.createElem(nodeName); 251 | } 252 | 253 | return node.canHaveChildren && !reSkip.test(nodeName) && !node.tagUrn ? data.frag.appendChild(node) : node; 254 | } 255 | 256 | function createDocumentFragment(ownerDocument, data){ 257 | if (!ownerDocument) { 258 | ownerDocument = document; 259 | } 260 | if(supportsUnknownElements){ 261 | return ownerDocument.createDocumentFragment(); 262 | } 263 | data = data || getExpandoData(ownerDocument); 264 | var clone = data.frag.cloneNode(), 265 | i = 0, 266 | elems = getElements(), 267 | l = elems.length; 268 | for(;i 10 | (a.textContent ? a.innerText ? "").toUpperCase().indexOf(m[3].toUpperCase()) >= 0 11 | 12 | class MailCatcher 13 | constructor: -> 14 | $("#messages").on "click", "tr", (e) => 15 | e.preventDefault() 16 | @loadMessage $(e.currentTarget).attr("data-message-id") 17 | 18 | $("input[name=search]").on "keyup", (e) => 19 | query = $.trim $(e.currentTarget).val() 20 | if query 21 | @searchMessages query 22 | else 23 | @clearSearch() 24 | 25 | $("#message").on "click", ".views .format.tab a", (e) => 26 | e.preventDefault() 27 | @loadMessageBody @selectedMessage(), $($(e.currentTarget).parent("li")).data("message-format") 28 | 29 | $("#message iframe").on "load", => 30 | @decorateMessageBody() 31 | 32 | $("#resizer").on "mousedown", (e) => 33 | e.preventDefault() 34 | events = 35 | mouseup: (e) => 36 | e.preventDefault() 37 | $(window).off(events) 38 | mousemove: (e) => 39 | e.preventDefault() 40 | @resizeTo e.clientY 41 | $(window).on(events) 42 | 43 | @resizeToSaved() 44 | 45 | $("nav.app .clear a").on "click", (e) => 46 | e.preventDefault() 47 | if confirm "You will lose all your received messages.\n\nAre you sure you want to clear all messages?" 48 | $.ajax 49 | url: new URL("messages", document.baseURI).toString() 50 | type: "DELETE" 51 | success: => 52 | @clearMessages() 53 | error: -> 54 | alert "Error while clearing all messages." 55 | 56 | $("nav.app .quit a").on "click", (e) => 57 | e.preventDefault() 58 | if confirm "You will lose all your received messages.\n\nAre you sure you want to quit?" 59 | @quitting = true 60 | $.ajax 61 | type: "DELETE" 62 | success: => 63 | @hasQuit() 64 | error: => 65 | @quitting = false 66 | alert "Error while quitting." 67 | 68 | @favcount = new Favcount($("""link[rel="icon"]""").attr("href")) 69 | 70 | key "up", => 71 | if @selectedMessage() 72 | @loadMessage $("#messages tr.selected").prevAll(":visible").first().data("message-id") 73 | else 74 | @loadMessage $("#messages tbody tr[data-message-id]").first().data("message-id") 75 | false 76 | 77 | key "down", => 78 | if @selectedMessage() 79 | @loadMessage $("#messages tr.selected").nextAll(":visible").data("message-id") 80 | else 81 | @loadMessage $("#messages tbody tr[data-message-id]:first").data("message-id") 82 | false 83 | 84 | key "⌘+up, ctrl+up", => 85 | @loadMessage $("#messages tbody tr[data-message-id]:visible").first().data("message-id") 86 | false 87 | 88 | key "⌘+down, ctrl+down", => 89 | @loadMessage $("#messages tbody tr[data-message-id]:visible").first().data("message-id") 90 | false 91 | 92 | key "left", => 93 | @openTab @previousTab() 94 | false 95 | 96 | key "right", => 97 | @openTab @nextTab() 98 | false 99 | 100 | key "backspace, delete", => 101 | id = @selectedMessage() 102 | if id? 103 | $.ajax 104 | url: new URL("messages/#{id}", document.baseURI).toString() 105 | type: "DELETE" 106 | success: => 107 | @removeMessage(id) 108 | 109 | error: -> 110 | alert "Error while removing message." 111 | false 112 | 113 | @refresh() 114 | @subscribe() 115 | 116 | # Only here because Safari's Date parsing *sucks* 117 | # We throw away the timezone, but you could use it for something... 118 | parseDateRegexp: /^(\d{4})[-\/\\](\d{2})[-\/\\](\d{2})(?:\s+|T)(\d{2})[:-](\d{2})[:-](\d{2})(?:([ +-]\d{2}:\d{2}|\s*\S+|Z?))?$/ 119 | parseDate: (date) -> 120 | if match = @parseDateRegexp.exec(date) 121 | new Date match[1], match[2] - 1, match[3], match[4], match[5], match[6], 0 122 | 123 | offsetTimeZone: (date) -> 124 | offset = Date.now().getTimezoneOffset() * 60000 #convert timezone difference to milliseconds 125 | date.setTime(date.getTime() - offset) 126 | date 127 | 128 | formatDate: (date) -> 129 | date &&= @parseDate(date) if typeof(date) == "string" 130 | date &&= @offsetTimeZone(date) 131 | date &&= date.toString("dddd, d MMM yyyy h:mm:ss tt") 132 | 133 | messagesCount: -> 134 | $("#messages tr").length - 1 135 | 136 | updateMessagesCount: -> 137 | @favcount.set(@messagesCount()) 138 | document.title = 'MailCatcher (' + @messagesCount() + ')' 139 | 140 | tabs: -> 141 | $("#message ul").children(".tab") 142 | 143 | getTab: (i) => 144 | $(@tabs()[i]) 145 | 146 | selectedTab: => 147 | @tabs().index($("#message li.tab.selected")) 148 | 149 | openTab: (i) => 150 | @getTab(i).children("a").click() 151 | 152 | previousTab: (tab)=> 153 | i = if tab || tab is 0 then tab else @selectedTab() - 1 154 | i = @tabs().length - 1 if i < 0 155 | if @getTab(i).is(":visible") 156 | i 157 | else 158 | @previousTab(i - 1) 159 | 160 | nextTab: (tab) => 161 | i = if tab then tab else @selectedTab() + 1 162 | i = 0 if i > @tabs().length - 1 163 | if @getTab(i).is(":visible") 164 | i 165 | else 166 | @nextTab(i + 1) 167 | 168 | haveMessage: (message) -> 169 | message = message.id if message.id? 170 | $("""#messages tbody tr[data-message-id="#{message}"]""").length > 0 171 | 172 | selectedMessage: -> 173 | $("#messages tr.selected").data "message-id" 174 | 175 | searchMessages: (query) -> 176 | selector = (":icontains('#{token}')" for token in query.split /\s+/).join("") 177 | $rows = $("#messages tbody tr") 178 | $rows.not(selector).hide() 179 | $rows.filter(selector).show() 180 | 181 | clearSearch: -> 182 | $("#messages tbody tr").show() 183 | 184 | addMessage: (message) -> 185 | $("").attr("data-message-id", message.id.toString()) 186 | .append($("").text(message.sender or "No sender").toggleClass("blank", !message.sender)) 187 | .append($("").text((message.recipients || []).join(", ") or "No recipients").toggleClass("blank", !message.recipients.length)) 188 | .append($("").text(message.subject or "No subject").toggleClass("blank", !message.subject)) 189 | .append($("").text(@formatDate(message.created_at))) 190 | .prependTo($("#messages tbody")) 191 | @updateMessagesCount() 192 | 193 | removeMessage: (id) -> 194 | messageRow = $("""#messages tbody tr[data-message-id="#{id}"]""") 195 | isSelected = messageRow.is(".selected") 196 | if isSelected 197 | switchTo = messageRow.next().data("message-id") || messageRow.prev().data("message-id") 198 | messageRow.remove() 199 | if isSelected 200 | if switchTo 201 | @loadMessage switchTo 202 | else 203 | @unselectMessage() 204 | @updateMessagesCount() 205 | 206 | clearMessages: -> 207 | $("#messages tbody tr").remove() 208 | @unselectMessage() 209 | @updateMessagesCount() 210 | 211 | scrollToRow: (row) -> 212 | relativePosition = row.offset().top - $("#messages").offset().top 213 | if relativePosition < 0 214 | $("#messages").scrollTop($("#messages").scrollTop() + relativePosition - 20) 215 | else 216 | overflow = relativePosition + row.height() - $("#messages").height() 217 | if overflow > 0 218 | $("#messages").scrollTop($("#messages").scrollTop() + overflow + 20) 219 | 220 | unselectMessage: -> 221 | $("#messages tbody, #message .metadata dd").empty() 222 | $("#message .metadata .attachments").hide() 223 | $("#message iframe").attr("src", "about:blank") 224 | null 225 | 226 | loadMessage: (id) -> 227 | id = id.id if id?.id? 228 | id ||= $("#messages tr.selected").attr "data-message-id" 229 | 230 | if id? 231 | $("#messages tbody tr:not([data-message-id='#{id}'])").removeClass("selected") 232 | messageRow = $("#messages tbody tr[data-message-id='#{id}']") 233 | messageRow.addClass("selected") 234 | @scrollToRow(messageRow) 235 | 236 | $.getJSON "messages/#{id}.json", (message) => 237 | $("#message .metadata dd.created_at").text(@formatDate message.created_at) 238 | $("#message .metadata dd.from").text(message.sender) 239 | $("#message .metadata dd.to").text((message.recipients || []).join(", ")) 240 | $("#message .metadata dd.subject").text(message.subject) 241 | $("#message .views .tab.format").each (i, el) -> 242 | $el = $(el) 243 | format = $el.attr("data-message-format") 244 | if $.inArray(format, message.formats) >= 0 245 | $el.find("a").attr("href", "messages/#{id}.#{format}") 246 | $el.show() 247 | else 248 | $el.hide() 249 | 250 | if $("#message .views .tab.selected:not(:visible)").length 251 | $("#message .views .tab.selected").removeClass("selected") 252 | $("#message .views .tab:visible:first").addClass("selected") 253 | 254 | if message.attachments.length 255 | $ul = $("
    ").appendTo($("#message .metadata dd.attachments").empty()) 256 | 257 | $.each message.attachments, (i, attachment) -> 258 | $ul.append($("
  • ").append($("").attr("href", "messages/#{id}/parts/#{attachment["cid"]}").addClass(attachment["type"].split("/", 1)[0]).addClass(attachment["type"].replace("/", "-")).text(attachment["filename"]))) 259 | $("#message .metadata .attachments").show() 260 | else 261 | $("#message .metadata .attachments").hide() 262 | 263 | $("#message .views .download a").attr("href", "messages/#{id}.eml") 264 | 265 | @loadMessageBody() 266 | 267 | loadMessageBody: (id, format) -> 268 | id ||= @selectedMessage() 269 | format ||= $("#message .views .tab.format.selected").attr("data-message-format") 270 | format ||= "html" 271 | 272 | $("""#message .views .tab[data-message-format="#{format}"]:not(.selected)""").addClass("selected") 273 | $("""#message .views .tab:not([data-message-format="#{format}"]).selected""").removeClass("selected") 274 | 275 | if id? 276 | $("#message iframe").attr("src", "messages/#{id}.#{format}") 277 | 278 | decorateMessageBody: -> 279 | format = $("#message .views .tab.format.selected").attr("data-message-format") 280 | 281 | switch format 282 | when "html" 283 | body = $("#message iframe").contents().find("body") 284 | $("a", body).attr("target", "_blank") 285 | when "plain" 286 | message_iframe = $("#message iframe").contents() 287 | text = message_iframe.text() 288 | 289 | # Escape special characters 290 | text = text.replace(/&/g, "&") 291 | text = text.replace(//g, ">") 293 | text = text.replace(/"/g, """) 294 | 295 | # Autolink text 296 | text = text.replace(/((http|ftp|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:\/~\+#]*[\w\-\@?^=%&\/~\+#])?)/g, """$1""") 297 | 298 | message_iframe.find("html").html("""#{text}""") 299 | 300 | refresh: -> 301 | $.getJSON "messages", (messages) => 302 | $.each messages, (i, message) => 303 | unless @haveMessage message 304 | @addMessage message 305 | @updateMessagesCount() 306 | 307 | subscribe: -> 308 | if WebSocket? 309 | @subscribeWebSocket() 310 | else 311 | @subscribePoll() 312 | 313 | subscribeWebSocket: -> 314 | secure = window.location.protocol is "https:" 315 | url = new URL("messages", document.baseURI) 316 | url.protocol = if secure then "wss" else "ws" 317 | @websocket = new WebSocket(url.toString()) 318 | @websocket.onmessage = (event) => 319 | data = JSON.parse(event.data) 320 | if data.type == "add" 321 | @addMessage(data.message) 322 | else if data.type == "remove" 323 | @removeMessage(data.id) 324 | else if data.type == "clear" 325 | @clearMessages() 326 | else if data.type == "quit" and not @quitting 327 | alert "MailCatcher has been quit" 328 | @hasQuit() 329 | 330 | subscribePoll: -> 331 | unless @refreshInterval? 332 | @refreshInterval = setInterval (=> @refresh()), 1000 333 | 334 | resizeToSavedKey: "mailcatcherSeparatorHeight" 335 | 336 | resizeTo: (height) -> 337 | $("#messages").css 338 | height: height - $("#messages").offset().top 339 | window.localStorage?.setItem(@resizeToSavedKey, height) 340 | 341 | resizeToSaved: -> 342 | height = parseInt(window.localStorage?.getItem(@resizeToSavedKey)) 343 | unless isNaN height 344 | @resizeTo height 345 | 346 | hasQuit: -> 347 | location.assign $("body > header h1 a").attr("href") 348 | 349 | $ -> window.MailCatcher = new MailCatcher 350 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/url.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | /** @type {boolean|undefined} */ 5 | Window.prototype.forceJURL = false; 6 | 7 | (function(scope) { 8 | 'use strict'; 9 | 10 | // feature detect for URL constructor 11 | var hasWorkingUrl = false; 12 | if (!scope.forceJURL) { 13 | try { 14 | var u = new URL('b', 'http://a'); 15 | u.pathname = 'c%20d'; 16 | hasWorkingUrl = u.href === 'http://a/c%20d'; 17 | } catch(e) {} 18 | } 19 | 20 | if (hasWorkingUrl) 21 | return; 22 | 23 | var relative = Object.create(null); 24 | relative['ftp'] = 21; 25 | relative['file'] = 0; 26 | relative['gopher'] = 70; 27 | relative['http'] = 80; 28 | relative['https'] = 443; 29 | relative['ws'] = 80; 30 | relative['wss'] = 443; 31 | 32 | var relativePathDotMapping = Object.create(null); 33 | relativePathDotMapping['%2e'] = '.'; 34 | relativePathDotMapping['.%2e'] = '..'; 35 | relativePathDotMapping['%2e.'] = '..'; 36 | relativePathDotMapping['%2e%2e'] = '..'; 37 | 38 | function isRelativeScheme(scheme) { 39 | return relative[scheme] !== undefined; 40 | } 41 | 42 | function invalid() { 43 | clear.call(this); 44 | this._isInvalid = true; 45 | } 46 | 47 | function IDNAToASCII(h) { 48 | if ('' == h) { 49 | invalid.call(this) 50 | } 51 | // XXX 52 | return h.toLowerCase() 53 | } 54 | 55 | function percentEscape(c) { 56 | var unicode = c.charCodeAt(0); 57 | if (unicode > 0x20 && 58 | unicode < 0x7F && 59 | // " # < > ? ` 60 | [0x22, 0x23, 0x3C, 0x3E, 0x3F, 0x60].indexOf(unicode) == -1 61 | ) { 62 | return c; 63 | } 64 | return encodeURIComponent(c); 65 | } 66 | 67 | function percentEscapeQuery(c) { 68 | // XXX This actually needs to encode c using encoding and then 69 | // convert the bytes one-by-one. 70 | 71 | var unicode = c.charCodeAt(0); 72 | if (unicode > 0x20 && 73 | unicode < 0x7F && 74 | // " # < > ` (do not escape '?') 75 | [0x22, 0x23, 0x3C, 0x3E, 0x60].indexOf(unicode) == -1 76 | ) { 77 | return c; 78 | } 79 | return encodeURIComponent(c); 80 | } 81 | 82 | var EOF = undefined, 83 | ALPHA = /[a-zA-Z]/, 84 | ALPHANUMERIC = /[a-zA-Z0-9\+\-\.]/; 85 | 86 | /** 87 | * @param {!string} input 88 | * @param {?string=} stateOverride 89 | * @param {(URL|string)=} base 90 | */ 91 | function parse(input, stateOverride, base) { 92 | function err(message) { 93 | errors.push(message) 94 | } 95 | 96 | var state = stateOverride || 'scheme start', 97 | cursor = 0, 98 | buffer = '', 99 | seenAt = false, 100 | seenBracket = false, 101 | errors = []; 102 | 103 | loop: while ((input[cursor - 1] != EOF || cursor == 0) && !this._isInvalid) { 104 | var c = input[cursor]; 105 | switch (state) { 106 | case 'scheme start': 107 | if (c && ALPHA.test(c)) { 108 | buffer += c.toLowerCase(); // ASCII-safe 109 | state = 'scheme'; 110 | } else if (!stateOverride) { 111 | buffer = ''; 112 | state = 'no scheme'; 113 | continue; 114 | } else { 115 | err('Invalid scheme.'); 116 | break loop; 117 | } 118 | break; 119 | 120 | case 'scheme': 121 | if (c && ALPHANUMERIC.test(c)) { 122 | buffer += c.toLowerCase(); // ASCII-safe 123 | } else if (':' == c) { 124 | this._scheme = buffer; 125 | buffer = ''; 126 | if (stateOverride) { 127 | break loop; 128 | } 129 | if (isRelativeScheme(this._scheme)) { 130 | this._isRelative = true; 131 | } 132 | if ('file' == this._scheme) { 133 | state = 'relative'; 134 | } else if (this._isRelative && base && base._scheme == this._scheme) { 135 | state = 'relative or authority'; 136 | } else if (this._isRelative) { 137 | state = 'authority first slash'; 138 | } else { 139 | state = 'scheme data'; 140 | } 141 | } else if (!stateOverride) { 142 | buffer = ''; 143 | cursor = 0; 144 | state = 'no scheme'; 145 | continue; 146 | } else if (EOF == c) { 147 | break loop; 148 | } else { 149 | err('Code point not allowed in scheme: ' + c) 150 | break loop; 151 | } 152 | break; 153 | 154 | case 'scheme data': 155 | if ('?' == c) { 156 | this._query = '?'; 157 | state = 'query'; 158 | } else if ('#' == c) { 159 | this._fragment = '#'; 160 | state = 'fragment'; 161 | } else { 162 | // XXX error handling 163 | if (EOF != c && '\t' != c && '\n' != c && '\r' != c) { 164 | this._schemeData += percentEscape(c); 165 | } 166 | } 167 | break; 168 | 169 | case 'no scheme': 170 | if (!base || !(isRelativeScheme(base._scheme))) { 171 | err('Missing scheme.'); 172 | invalid.call(this); 173 | } else { 174 | state = 'relative'; 175 | continue; 176 | } 177 | break; 178 | 179 | case 'relative or authority': 180 | if ('/' == c && '/' == input[cursor+1]) { 181 | state = 'authority ignore slashes'; 182 | } else { 183 | err('Expected /, got: ' + c); 184 | state = 'relative'; 185 | continue 186 | } 187 | break; 188 | 189 | case 'relative': 190 | this._isRelative = true; 191 | if ('file' != this._scheme) 192 | this._scheme = base._scheme; 193 | if (EOF == c) { 194 | this._host = base._host; 195 | this._port = base._port; 196 | this._path = base._path.slice(); 197 | this._query = base._query; 198 | this._username = base._username; 199 | this._password = base._password; 200 | break loop; 201 | } else if ('/' == c || '\\' == c) { 202 | if ('\\' == c) 203 | err('\\ is an invalid code point.'); 204 | state = 'relative slash'; 205 | } else if ('?' == c) { 206 | this._host = base._host; 207 | this._port = base._port; 208 | this._path = base._path.slice(); 209 | this._query = '?'; 210 | this._username = base._username; 211 | this._password = base._password; 212 | state = 'query'; 213 | } else if ('#' == c) { 214 | this._host = base._host; 215 | this._port = base._port; 216 | this._path = base._path.slice(); 217 | this._query = base._query; 218 | this._fragment = '#'; 219 | this._username = base._username; 220 | this._password = base._password; 221 | state = 'fragment'; 222 | } else { 223 | var nextC = input[cursor+1] 224 | var nextNextC = input[cursor+2] 225 | if ( 226 | 'file' != this._scheme || !ALPHA.test(c) || 227 | (nextC != ':' && nextC != '|') || 228 | (EOF != nextNextC && '/' != nextNextC && '\\' != nextNextC && '?' != nextNextC && '#' != nextNextC)) { 229 | this._host = base._host; 230 | this._port = base._port; 231 | this._username = base._username; 232 | this._password = base._password; 233 | this._path = base._path.slice(); 234 | this._path.pop(); 235 | } 236 | state = 'relative path'; 237 | continue; 238 | } 239 | break; 240 | 241 | case 'relative slash': 242 | if ('/' == c || '\\' == c) { 243 | if ('\\' == c) { 244 | err('\\ is an invalid code point.'); 245 | } 246 | if ('file' == this._scheme) { 247 | state = 'file host'; 248 | } else { 249 | state = 'authority ignore slashes'; 250 | } 251 | } else { 252 | if ('file' != this._scheme) { 253 | this._host = base._host; 254 | this._port = base._port; 255 | this._username = base._username; 256 | this._password = base._password; 257 | } 258 | state = 'relative path'; 259 | continue; 260 | } 261 | break; 262 | 263 | case 'authority first slash': 264 | if ('/' == c) { 265 | state = 'authority second slash'; 266 | } else { 267 | err("Expected '/', got: " + c); 268 | state = 'authority ignore slashes'; 269 | continue; 270 | } 271 | break; 272 | 273 | case 'authority second slash': 274 | state = 'authority ignore slashes'; 275 | if ('/' != c) { 276 | err("Expected '/', got: " + c); 277 | continue; 278 | } 279 | break; 280 | 281 | case 'authority ignore slashes': 282 | if ('/' != c && '\\' != c) { 283 | state = 'authority'; 284 | continue; 285 | } else { 286 | err('Expected authority, got: ' + c); 287 | } 288 | break; 289 | 290 | case 'authority': 291 | if ('@' == c) { 292 | if (seenAt) { 293 | err('@ already seen.'); 294 | buffer += '%40'; 295 | } 296 | seenAt = true; 297 | for (var i = 0; i < buffer.length; i++) { 298 | var cp = buffer[i]; 299 | if ('\t' == cp || '\n' == cp || '\r' == cp) { 300 | err('Invalid whitespace in authority.'); 301 | continue; 302 | } 303 | // XXX check URL code points 304 | if (':' == cp && null === this._password) { 305 | this._password = ''; 306 | continue; 307 | } 308 | var tempC = percentEscape(cp); 309 | (null !== this._password) ? this._password += tempC : this._username += tempC; 310 | } 311 | buffer = ''; 312 | } else if (EOF == c || '/' == c || '\\' == c || '?' == c || '#' == c) { 313 | cursor -= buffer.length; 314 | buffer = ''; 315 | state = 'host'; 316 | continue; 317 | } else { 318 | buffer += c; 319 | } 320 | break; 321 | 322 | case 'file host': 323 | if (EOF == c || '/' == c || '\\' == c || '?' == c || '#' == c) { 324 | if (buffer.length == 2 && ALPHA.test(buffer[0]) && (buffer[1] == ':' || buffer[1] == '|')) { 325 | state = 'relative path'; 326 | } else if (buffer.length == 0) { 327 | state = 'relative path start'; 328 | } else { 329 | this._host = IDNAToASCII.call(this, buffer); 330 | buffer = ''; 331 | state = 'relative path start'; 332 | } 333 | continue; 334 | } else if ('\t' == c || '\n' == c || '\r' == c) { 335 | err('Invalid whitespace in file host.'); 336 | } else { 337 | buffer += c; 338 | } 339 | break; 340 | 341 | case 'host': 342 | case 'hostname': 343 | if (':' == c && !seenBracket) { 344 | // XXX host parsing 345 | this._host = IDNAToASCII.call(this, buffer); 346 | buffer = ''; 347 | state = 'port'; 348 | if ('hostname' == stateOverride) { 349 | break loop; 350 | } 351 | } else if (EOF == c || '/' == c || '\\' == c || '?' == c || '#' == c) { 352 | this._host = IDNAToASCII.call(this, buffer); 353 | buffer = ''; 354 | state = 'relative path start'; 355 | if (stateOverride) { 356 | break loop; 357 | } 358 | continue; 359 | } else if ('\t' != c && '\n' != c && '\r' != c) { 360 | if ('[' == c) { 361 | seenBracket = true; 362 | } else if (']' == c) { 363 | seenBracket = false; 364 | } 365 | buffer += c; 366 | } else { 367 | err('Invalid code point in host/hostname: ' + c); 368 | } 369 | break; 370 | 371 | case 'port': 372 | if (/[0-9]/.test(c)) { 373 | buffer += c; 374 | } else if (EOF == c || '/' == c || '\\' == c || '?' == c || '#' == c || stateOverride) { 375 | if ('' != buffer) { 376 | var temp = parseInt(buffer, 10); 377 | if (temp != relative[this._scheme]) { 378 | this._port = temp + ''; 379 | } 380 | buffer = ''; 381 | } 382 | if (stateOverride) { 383 | break loop; 384 | } 385 | state = 'relative path start'; 386 | continue; 387 | } else if ('\t' == c || '\n' == c || '\r' == c) { 388 | err('Invalid code point in port: ' + c); 389 | } else { 390 | invalid.call(this); 391 | } 392 | break; 393 | 394 | case 'relative path start': 395 | if ('\\' == c) 396 | err("'\\' not allowed in path."); 397 | state = 'relative path'; 398 | if ('/' != c && '\\' != c) { 399 | continue; 400 | } 401 | break; 402 | 403 | case 'relative path': 404 | if (EOF == c || '/' == c || '\\' == c || (!stateOverride && ('?' == c || '#' == c))) { 405 | if ('\\' == c) { 406 | err('\\ not allowed in relative path.'); 407 | } 408 | var tmp; 409 | if (tmp = relativePathDotMapping[buffer.toLowerCase()]) { 410 | buffer = tmp; 411 | } 412 | if ('..' == buffer) { 413 | this._path.pop(); 414 | if ('/' != c && '\\' != c) { 415 | this._path.push(''); 416 | } 417 | } else if ('.' == buffer && '/' != c && '\\' != c) { 418 | this._path.push(''); 419 | } else if ('.' != buffer) { 420 | if ('file' == this._scheme && this._path.length == 0 && buffer.length == 2 && ALPHA.test(buffer[0]) && buffer[1] == '|') { 421 | buffer = buffer[0] + ':'; 422 | } 423 | this._path.push(buffer); 424 | } 425 | buffer = ''; 426 | if ('?' == c) { 427 | this._query = '?'; 428 | state = 'query'; 429 | } else if ('#' == c) { 430 | this._fragment = '#'; 431 | state = 'fragment'; 432 | } 433 | } else if ('\t' != c && '\n' != c && '\r' != c) { 434 | buffer += percentEscape(c); 435 | } 436 | break; 437 | 438 | case 'query': 439 | if (!stateOverride && '#' == c) { 440 | this._fragment = '#'; 441 | state = 'fragment'; 442 | } else if (EOF != c && '\t' != c && '\n' != c && '\r' != c) { 443 | this._query += percentEscapeQuery(c); 444 | } 445 | break; 446 | 447 | case 'fragment': 448 | if (EOF != c && '\t' != c && '\n' != c && '\r' != c) { 449 | this._fragment += c; 450 | } 451 | break; 452 | } 453 | 454 | cursor++; 455 | } 456 | } 457 | 458 | function clear() { 459 | this._scheme = ''; 460 | this._schemeData = ''; 461 | this._username = ''; 462 | this._password = null; 463 | this._host = ''; 464 | this._port = ''; 465 | this._path = []; 466 | this._query = ''; 467 | this._fragment = ''; 468 | this._isInvalid = false; 469 | this._isRelative = false; 470 | } 471 | 472 | // Does not process domain names or IP addresses. 473 | // Does not handle encoding for the query parameter. 474 | /** 475 | * @constructor 476 | * @extends {URL} 477 | * @param {!string} url 478 | * @param {(URL|string)=} base 479 | */ 480 | function jURL(url, base /* , encoding */) { 481 | if (base !== undefined && !(base instanceof jURL)) 482 | base = new jURL(String(base)); 483 | 484 | this._url = '' + url; 485 | clear.call(this); 486 | 487 | var input = this._url.replace(/^[ \t\r\n\f]+|[ \t\r\n\f]+$/g, ''); 488 | // encoding = encoding || 'utf-8' 489 | 490 | parse.call(this, input, null, base); 491 | } 492 | 493 | jURL.prototype = { 494 | toString: function() { 495 | return this.href; 496 | }, 497 | get href() { 498 | if (this._isInvalid) 499 | return this._url; 500 | 501 | var authority = ''; 502 | if ('' != this._username || null != this._password) { 503 | authority = this._username + 504 | (null != this._password ? ':' + this._password : '') + '@'; 505 | } 506 | 507 | return this.protocol + 508 | (this._isRelative ? '//' + authority + this.host : '') + 509 | this.pathname + this._query + this._fragment; 510 | }, 511 | set href(href) { 512 | clear.call(this); 513 | parse.call(this, href); 514 | }, 515 | 516 | get protocol() { 517 | return this._scheme + ':'; 518 | }, 519 | set protocol(protocol) { 520 | if (this._isInvalid) 521 | return; 522 | parse.call(this, protocol + ':', 'scheme start'); 523 | }, 524 | 525 | get host() { 526 | return this._isInvalid ? '' : this._port ? 527 | this._host + ':' + this._port : this._host; 528 | }, 529 | set host(host) { 530 | if (this._isInvalid || !this._isRelative) 531 | return; 532 | parse.call(this, host, 'host'); 533 | }, 534 | 535 | get hostname() { 536 | return this._host; 537 | }, 538 | set hostname(hostname) { 539 | if (this._isInvalid || !this._isRelative) 540 | return; 541 | parse.call(this, hostname, 'hostname'); 542 | }, 543 | 544 | get port() { 545 | return this._port; 546 | }, 547 | set port(port) { 548 | if (this._isInvalid || !this._isRelative) 549 | return; 550 | parse.call(this, port, 'port'); 551 | }, 552 | 553 | get pathname() { 554 | return this._isInvalid ? '' : this._isRelative ? 555 | '/' + this._path.join('/') : this._schemeData; 556 | }, 557 | set pathname(pathname) { 558 | if (this._isInvalid || !this._isRelative) 559 | return; 560 | this._path = []; 561 | parse.call(this, pathname, 'relative path start'); 562 | }, 563 | 564 | get search() { 565 | return this._isInvalid || !this._query || '?' == this._query ? 566 | '' : this._query; 567 | }, 568 | set search(search) { 569 | if (this._isInvalid || !this._isRelative) 570 | return; 571 | this._query = '?'; 572 | if ('?' == search[0]) 573 | search = search.slice(1); 574 | parse.call(this, search, 'query'); 575 | }, 576 | 577 | get hash() { 578 | return this._isInvalid || !this._fragment || '#' == this._fragment ? 579 | '' : this._fragment; 580 | }, 581 | set hash(hash) { 582 | if (this._isInvalid) 583 | return; 584 | if(!hash) { 585 | this._fragment = ''; 586 | return; 587 | } 588 | this._fragment = '#'; 589 | if ('#' == hash[0]) 590 | hash = hash.slice(1); 591 | parse.call(this, hash, 'fragment'); 592 | }, 593 | 594 | get origin() { 595 | var host; 596 | if (this._isInvalid || !this._scheme) { 597 | return ''; 598 | } 599 | // javascript: Gecko returns String(""), WebKit/Blink String("null") 600 | // Gecko throws error for "data://" 601 | // data: Gecko returns "", Blink returns "data://", WebKit returns "null" 602 | // Gecko returns String("") for file: mailto: 603 | // WebKit/Blink returns String("SCHEME://") for file: mailto: 604 | switch (this._scheme) { 605 | case 'data': 606 | case 'file': 607 | case 'javascript': 608 | case 'mailto': 609 | return 'null'; 610 | } 611 | host = this.host; 612 | if (!host) { 613 | return ''; 614 | } 615 | return this._scheme + '://' + host; 616 | } 617 | }; 618 | 619 | // Copy over the static methods 620 | var OriginalURL = scope.URL; 621 | if (OriginalURL) { 622 | jURL['createObjectURL'] = function(blob) { 623 | // IE extension allows a second optional options argument. 624 | // http://msdn.microsoft.com/en-us/library/ie/hh772302(v=vs.85).aspx 625 | return OriginalURL.createObjectURL.apply(OriginalURL, arguments); 626 | }; 627 | jURL['revokeObjectURL'] = function(url) { 628 | OriginalURL.revokeObjectURL(url); 629 | }; 630 | } 631 | 632 | scope.URL = jURL; 633 | 634 | })(window); 635 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/date.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Version: 1.0 Alpha-1 3 | * Build Date: 13-Nov-2007 4 | * Copyright (c) 2006-2007, Coolite Inc. (http://www.coolite.com/). All rights reserved. 5 | * License: Licensed under The MIT License. See license.txt and http://www.datejs.com/license/. 6 | * Website: http://www.datejs.com/ or http://www.coolite.com/datejs/ 7 | */ 8 | 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"}}; 9 | Date.getMonthNumberFromName=function(name){var n=Date.CultureInfo.monthNames,m=Date.CultureInfo.abbreviatedMonthNames,s=name.toLowerCase();for(var i=0;idate)?1:(this=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;} 14 | var x=config;if(x.millisecond||x.milliseconds){this.addMilliseconds(x.millisecond||x.milliseconds);} 15 | if(x.second||x.seconds){this.addSeconds(x.second||x.seconds);} 16 | if(x.minute||x.minutes){this.addMinutes(x.minute||x.minutes);} 17 | if(x.hour||x.hours){this.addHours(x.hour||x.hours);} 18 | if(x.month||x.months){this.addMonths(x.month||x.months);} 19 | if(x.year||x.years){this.addYears(x.year||x.years);} 20 | if(x.day||x.days){this.addDays(x.day||x.days);} 21 | return this;};Date._validate=function(value,min,max,name){if(typeof value!="number"){throw new TypeError(value+" is not a Number.");}else if(valuemax){throw new RangeError(value+" is not a valid value for "+name+".");} 22 | 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;} 23 | if(!x.second&&x.second!==0){x.second=-1;} 24 | if(!x.minute&&x.minute!==0){x.minute=-1;} 25 | if(!x.hour&&x.hour!==0){x.hour=-1;} 26 | if(!x.day&&x.day!==0){x.day=-1;} 27 | if(!x.month&&x.month!==0){x.month=-1;} 28 | if(!x.year&&x.year!==0){x.year=-1;} 29 | if(x.millisecond!=-1&&Date.validateMillisecond(x.millisecond)){this.addMilliseconds(x.millisecond-this.getMilliseconds());} 30 | if(x.second!=-1&&Date.validateSecond(x.second)){this.addSeconds(x.second-this.getSeconds());} 31 | if(x.minute!=-1&&Date.validateMinute(x.minute)){this.addMinutes(x.minute-this.getMinutes());} 32 | if(x.hour!=-1&&Date.validateHour(x.hour)){this.addHours(x.hour-this.getHours());} 33 | if(x.month!==-1&&Date.validateMonth(x.month)){this.addMonths(x.month-this.getMonth());} 34 | if(x.year!=-1&&Date.validateYear(x.year)){this.addYears(x.year-this.getFullYear());} 35 | if(x.day!=-1&&Date.validateDay(x.day,this.getFullYear(),this.getMonth())){this.addDays(x.day-this.getDate());} 36 | if(x.timezone){this.setTimezone(x.timezone);} 37 | if(x.timezoneOffset){this.setTimezoneOffset(x.timezoneOffset);} 38 | 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;} 39 | 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;}} 40 | return w;};Date.prototype.isDST=function(){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();}; 41 | 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;} 42 | return this.moveToDayOfWeek(n,this._orient);};};for(var i=0;i0&&!last){try{q=d.call(this,r[1]);}catch(ex){last=true;}}else{last=true;} 70 | if(!last&&q[1].length===0){last=true;} 71 | if(!last){var qx=[];for(var j=0;j0){rx[0]=rx[0].concat(p[0]);rx[1]=p[1];}} 73 | if(rx[1].length1){args=Array.prototype.slice.call(arguments);}else if(arguments[0]instanceof Array){args=arguments[0];} 80 | if(args){for(var i=0,px=args.shift();i2)?n:(n+(((n+2000)Date.getDaysInMonth(this.year,this.month)){throw new RangeError(this.day+" is not a valid value for days.");} 84 | 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});} 85 | return r;},finish:function(x){x=(x instanceof Array)?flattenAndCompact(x):[x];if(x.length===0){return null;} 86 | for(var i=0;i