Repository: sj26/mailcatcher Branch: main Commit: fbe811a53aea Files: 48 Total size: 412.7 KB Directory structure: gitextract_k7vfh5vh/ ├── .dockerignore ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── Dockerfile ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── assets/ │ ├── javascripts/ │ │ └── mailcatcher.js.coffee │ └── stylesheets/ │ └── mailcatcher.css.sass ├── bin/ │ ├── catchmail │ └── mailcatcher ├── examples/ │ ├── attachmail │ ├── attachment │ ├── breaking │ ├── dotmail │ ├── htmlmail │ ├── mail │ ├── multipartmail │ ├── multipartmail-with-utf8 │ ├── plainlinkmail │ ├── plainmail │ ├── quoted_printable_htmlmail │ ├── unknownmail │ └── xhtmlmail ├── lib/ │ ├── mail_catcher/ │ │ ├── bus.rb │ │ ├── mail.rb │ │ ├── smtp.rb │ │ ├── version.rb │ │ ├── web/ │ │ │ ├── application.rb │ │ │ └── assets.rb │ │ └── web.rb │ ├── mail_catcher.rb │ └── mailcatcher.rb ├── mailcatcher.gemspec ├── spec/ │ ├── clear_spec.rb │ ├── command_spec.rb │ ├── delivery_spec.rb │ ├── quit_spec.rb │ └── spec_helper.rb ├── vendor/ │ └── assets/ │ └── javascripts/ │ ├── date.js │ ├── favcount.js │ ├── jquery.js │ ├── keymaster.js │ ├── modernizr.js │ └── url.js └── views/ ├── 404.erb └── index.erb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # We don't use this repo's files to build the Docker image, we just gem install * ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [ main ] pull_request: branches: [ main ] workflow_dispatch: ~ jobs: test: strategy: matrix: os: [ubuntu-latest, macos-latest] ruby-version: ['3.1', '3.2', '3.3'] runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - uses: actions/setup-node@v3 - uses: browser-actions/setup-chrome@latest - uses: nanasess/setup-chromedriver@master - name: Run tests run: bundle exec rake test timeout-minutes: 5 - name: Upload test artifacts uses: actions/upload-artifact@v3 if: always() with: name: test-artifacts path: tmp ================================================ FILE: .gitignore ================================================ # Caches /.bundle /.sass-cache # Gemfile locks ignored for gems /Gemfile.lock # Generated documentation and assets /doc /public/assets # Build gems *.gem # Temp area, used for testing artifacts /tmp ================================================ FILE: Dockerfile ================================================ FROM ruby:3.3-alpine MAINTAINER Samuel Cochran # Use --build-arg VERSION=... to override # or `rake docker VERSION=...` ARG VERSION=0.10.0 # sqlite3 aarch64 is broken on alpine, so use ruby: # https://github.com/sparklemotion/sqlite3-ruby/issues/372 RUN apk add --no-cache build-base sqlite-libs sqlite-dev && \ ( [ "$(uname -m)" != "aarch64" ] || gem install sqlite3 --version="~> 1.3" --platform=ruby ) && \ gem install mailcatcher -v "$VERSION" && \ apk del --rdepends --purge build-base sqlite-dev EXPOSE 1025 1080 ENTRYPOINT ["mailcatcher", "--foreground"] CMD ["--ip", "0.0.0.0"] ================================================ FILE: Gemfile ================================================ # frozen_string_literal: true source "https://rubygems.org" gemspec ================================================ FILE: LICENSE ================================================ Copyright (c) 2010-2011 Samuel Cochran Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # MailCatcher Catches mail and serves it through a dream. MailCatcher runs a super simple SMTP server which catches any message sent to it to display in a web interface. Run mailcatcher, set your favourite app to deliver to smtp://127.0.0.1:1025 instead of your default SMTP server, then check out http://127.0.0.1:1080 to see the mail that's arrived so far. ![MailCatcher screenshot](https://cloud.githubusercontent.com/assets/14028/14093249/4100f904-f598-11e5-936b-e6a396f18e39.png) ## Features * Catches all mail and stores it for display. * Shows HTML, Plain Text and Source version of messages, as applicable. * Rewrites HTML enabling display of embedded, inline images/etc and opens links in a new window. * Lists attachments and allows separate downloading of parts. * Download original email to view in your native mail client(s). * Command line options to override the default SMTP/HTTP IP and port settings. * Mail appears instantly if your browser supports [WebSockets][websockets], otherwise updates every thirty seconds. * Runs as a daemon in the background, optionally in foreground. * Sendmail-analogue command, `catchmail`, makes using mailcatcher from PHP a lot easier. * Keyboard navigation between messages ## How 1. `gem install mailcatcher` 2. `mailcatcher` 3. Go to http://127.0.0.1:1080/ 4. Send mail through smtp://127.0.0.1:1025 ### Command Line Options Use `mailcatcher --help` to see the command line options. ``` Usage: mailcatcher [options] MailCatcher v0.8.0 --ip IP Set the ip address of both servers --smtp-ip IP Set the ip address of the smtp server --smtp-port PORT Set the port of the smtp server --http-ip IP Set the ip address of the http server --http-port PORT Set the port address of the http server --messages-limit COUNT Only keep up to COUNT most recent messages --http-path PATH Add a prefix to all HTTP paths --no-quit Don't allow quitting the process -f, --foreground Run in the foreground -b, --browse Open web browser -v, --verbose Be more verbose -h, --help Display this help information --version Display the current version ``` ### Upgrading Upgrading works the same as installation: ``` gem install mailcatcher ``` ### Ruby If you have trouble with the setup commands, make sure you have [Ruby installed](https://www.ruby-lang.org/en/documentation/installation/): ``` ruby -v gem environment ``` 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`. If you encounter issues installing [thin](https://rubygems.org/gems/thin), try: ``` gem install thin -v 1.5.1 -- --with-cflags="-Wno-error=implicit-function-declaration" ``` ### Bundler Please don't put mailcatcher into your Gemfile. It will conflict with your application's gems at some point. Instead, pop a note in your README stating you use mailcatcher, and to run `gem install mailcatcher` then `mailcatcher` to get started. ### RVM Under RVM your mailcatcher command may only be available under the ruby you install mailcatcher into. To prevent this, and to prevent gem conflicts, install mailcatcher into a dedicated gemset with a wrapper script: rvm default@mailcatcher --create do gem install mailcatcher ln -s "$(rvm default@mailcatcher do rvm wrapper show mailcatcher)" "$rvm_bin_path/" ### Rails To set up your rails app, I recommend adding this to your `environments/development.rb`: config.action_mailer.delivery_method = :smtp config.action_mailer.smtp_settings = { :address => '127.0.0.1', :port => 1025 } config.action_mailer.raise_delivery_errors = false ### PHP For projects using PHP, or PHP frameworks and application platforms like Drupal, you can set [PHP's mail configuration](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: sendmail_path = /usr/bin/env catchmail -f some@from.address You can do this in your [Apache configuration](https://www.php.net/manual/en/configuration.changes.php) like so: php_admin_value sendmail_path "/usr/bin/env catchmail -f some@from.address" If you've installed via RVM this probably won't work unless you've manually added your RVM bin paths to your system environment's PATH. In that case, run `which catchmail` and put that path into the `sendmail_path` directive above instead of `/usr/bin/env catchmail`. If starting `mailcatcher` on alternative SMTP IP and/or port with parameters like `--smtp-ip 192.168.0.1 --smtp-port 10025`, add the same parameters to your `catchmail` command: sendmail_path = /usr/bin/env catchmail --smtp-ip 192.160.0.1 --smtp-port 10025 -f some@from.address ### Django For use in Django, add the following configuration to your projects' settings.py ```python if DEBUG: EMAIL_HOST = '127.0.0.1' EMAIL_HOST_USER = '' EMAIL_HOST_PASSWORD = '' EMAIL_PORT = 1025 EMAIL_USE_TLS = False ``` ### Docker There is a Docker image available [on Docker Hub](https://hub.docker.com/r/sj26/mailcatcher): ``` $ docker run -p 1080 -p 1025 sj26/mailcatcher Unable to find image 'sj26/mailcatcher:latest' locally latest: Pulling from sj26/mailcatcher 8c6d1654570f: Already exists f5649d186f41: Already exists b850834ea1df: Already exists d6ac1a07fd46: Pull complete b609298bc3c9: Pull complete ab05825ece51: Pull complete Digest: sha256:b17c45de08a0a82b012d90d4bd048620952c475f5655c61eef373318de6c0855 Status: Downloaded newer image for sj26/mailcatcher:latest Starting MailCatcher v0.9.0 ==> smtp://0.0.0.0:1025 ==> http://0.0.0.0:1080 ``` 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. ### API A fairly RESTful URL schema means you can download a list of messages in JSON from `/messages`, each message's metadata with `/messages/:id.json`, and then the pertinent parts with `/messages/:id.html` and `/messages/:id.plain` for the default HTML and plain text version, `/messages/:id/parts/:cid` for individual attachments by CID, or the whole message with `/messages/:id.source`. ## Caveats * 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. * 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. ## Thanks MailCatcher is just a mishmash of other people's hard work. Thank you so much to the people who have built the wonderful guts on which this project relies. ## Donations I work on MailCatcher mostly in my own spare time. If you've found Mailcatcher useful and would like to help feed me and fund continued development and new features, please [donate via PayPal][donate]. If you'd like a specific feature added to MailCatcher and are willing to pay for it, please [email me](mailto:sj26@sj26.com). ## License Copyright © 2010-2019 Samuel Cochran (sj26@sj26.com). Released under the MIT License, see [LICENSE][license] for details. [donate]: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=522WUPLRWUSKE [license]: https://github.com/sj26/mailcatcher/blob/master/LICENSE [mailcatcher-github]: https://github.com/sj26/mailcatcher [mailcatcher-issues]: https://github.com/sj26/mailcatcher/issues [websockets]: https://tools.ietf.org/html/rfc6455 ================================================ FILE: Rakefile ================================================ # frozen_string_literal: true require "fileutils" require "rubygems" require "mail_catcher/version" # XXX: Would prefer to use Rake::SprocketsTask but can't populate # non-digest assets, and we don't want sprockets at runtime so # can't use manifest directly. Perhaps index.html should be # precompiled with digest assets paths? desc "Compile assets" task "assets" do compiled_path = File.expand_path("../public/assets", __FILE__) FileUtils.mkdir_p(compiled_path) require "mail_catcher/web/assets" sprockets = MailCatcher::Web::Assets sprockets.css_compressor = :sass sprockets.js_compressor = :uglifier sprockets.each_logical_path(/(\Amailcatcher\.(js|css)|\.(xsl|png)\Z)/) do |logical_path| if asset = sprockets.find_asset(logical_path) target = File.join(compiled_path, logical_path) asset.write_to target end end end desc "Package as Gem" task "package" => ["assets"] do require "rubygems/package" require "rubygems/specification" spec_file = File.expand_path("../mailcatcher.gemspec", __FILE__) spec = Gem::Specification.load(spec_file) Gem::Package.build spec end desc "Release Gem to RubyGems" task "release" => ["package"] do %x[gem push mailcatcher-#{MailCatcher::VERSION}.gem] end desc "Build and push Docker images (optional: VERSION=#{MailCatcher::VERSION})" task "docker" do version = ENV.fetch("VERSION", MailCatcher::VERSION) Dir.chdir(__dir__) do system "docker", "buildx", "build", # Push straight to Docker Hub (only way to do multi-arch??) "--push", # Build for both intel and arm (apple, graviton, etc) "--platform", "linux/amd64", "--platform", "linux/arm64", # Version respected within Dockerfile "--build-arg", "VERSION=#{version}", # Push latest and version "-t", "sj26/mailcatcher:latest", "-t", "sj26/mailcatcher:v#{version}", # Use current dir as context "." end end require "rdoc/task" RDoc::Task.new(:rdoc => "doc",:clobber_rdoc => "doc:clean", :rerdoc => "doc:force") do |rdoc| rdoc.title = "MailCatcher #{MailCatcher::VERSION}" rdoc.rdoc_dir = "doc" rdoc.main = "README.md" rdoc.rdoc_files.include "lib/**/*.rb" end require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:test) do |rspec| rspec.rspec_opts = "--format doc" end task :test => :assets task :default => :test ================================================ FILE: assets/javascripts/mailcatcher.js.coffee ================================================ #= require modernizr #= require jquery #= require date #= require favcount #= require keymaster #= require url # Add a new jQuery selector expression which does a case-insensitive :contains jQuery.expr.pseudos.icontains = (a, i, m) -> (a.textContent ? a.innerText ? "").toUpperCase().indexOf(m[3].toUpperCase()) >= 0 class MailCatcher constructor: -> $("#messages").on "click", "tr", (e) => e.preventDefault() @loadMessage $(e.currentTarget).attr("data-message-id") $("input[name=search]").on "keyup", (e) => query = $.trim $(e.currentTarget).val() if query @searchMessages query else @clearSearch() $("#message").on "click", ".views .format.tab a", (e) => e.preventDefault() @loadMessageBody @selectedMessage(), $($(e.currentTarget).parent("li")).data("message-format") $("#message iframe").on "load", => @decorateMessageBody() $("#resizer").on "mousedown", (e) => e.preventDefault() events = mouseup: (e) => e.preventDefault() $(window).off(events) mousemove: (e) => e.preventDefault() @resizeTo e.clientY $(window).on(events) @resizeToSaved() $("nav.app .clear a").on "click", (e) => e.preventDefault() if confirm "You will lose all your received messages.\n\nAre you sure you want to clear all messages?" $.ajax url: new URL("messages", document.baseURI).toString() type: "DELETE" success: => @clearMessages() error: -> alert "Error while clearing all messages." $("nav.app .quit a").on "click", (e) => e.preventDefault() if confirm "You will lose all your received messages.\n\nAre you sure you want to quit?" @quitting = true $.ajax type: "DELETE" success: => @hasQuit() error: => @quitting = false alert "Error while quitting." @favcount = new Favcount($("""link[rel="icon"]""").attr("href")) key "up", => if @selectedMessage() @loadMessage $("#messages tr.selected").prevAll(":visible").first().data("message-id") else @loadMessage $("#messages tbody tr[data-message-id]").first().data("message-id") false key "down", => if @selectedMessage() @loadMessage $("#messages tr.selected").nextAll(":visible").data("message-id") else @loadMessage $("#messages tbody tr[data-message-id]:first").data("message-id") false key "⌘+up, ctrl+up", => @loadMessage $("#messages tbody tr[data-message-id]:visible").first().data("message-id") false key "⌘+down, ctrl+down", => @loadMessage $("#messages tbody tr[data-message-id]:visible").first().data("message-id") false key "left", => @openTab @previousTab() false key "right", => @openTab @nextTab() false key "backspace, delete", => id = @selectedMessage() if id? $.ajax url: new URL("messages/#{id}", document.baseURI).toString() type: "DELETE" success: => @removeMessage(id) error: -> alert "Error while removing message." false @refresh() @subscribe() # Only here because Safari's Date parsing *sucks* # We throw away the timezone, but you could use it for something... parseDateRegexp: /^(\d{4})[-\/\\](\d{2})[-\/\\](\d{2})(?:\s+|T)(\d{2})[:-](\d{2})[:-](\d{2})(?:([ +-]\d{2}:\d{2}|\s*\S+|Z?))?$/ parseDate: (date) -> if match = @parseDateRegexp.exec(date) new Date match[1], match[2] - 1, match[3], match[4], match[5], match[6], 0 offsetTimeZone: (date) -> offset = Date.now().getTimezoneOffset() * 60000 #convert timezone difference to milliseconds date.setTime(date.getTime() - offset) date formatDate: (date) -> date &&= @parseDate(date) if typeof(date) == "string" date &&= @offsetTimeZone(date) date &&= date.toString("dddd, d MMM yyyy h:mm:ss tt") messagesCount: -> $("#messages tr").length - 1 updateMessagesCount: -> @favcount.set(@messagesCount()) document.title = 'MailCatcher (' + @messagesCount() + ')' tabs: -> $("#message ul").children(".tab") getTab: (i) => $(@tabs()[i]) selectedTab: => @tabs().index($("#message li.tab.selected")) openTab: (i) => @getTab(i).children("a").click() previousTab: (tab)=> i = if tab || tab is 0 then tab else @selectedTab() - 1 i = @tabs().length - 1 if i < 0 if @getTab(i).is(":visible") i else @previousTab(i - 1) nextTab: (tab) => i = if tab then tab else @selectedTab() + 1 i = 0 if i > @tabs().length - 1 if @getTab(i).is(":visible") i else @nextTab(i + 1) haveMessage: (message) -> message = message.id if message.id? $("""#messages tbody tr[data-message-id="#{message}"]""").length > 0 selectedMessage: -> $("#messages tr.selected").data "message-id" searchMessages: (query) -> selector = (":icontains('#{token}')" for token in query.split /\s+/).join("") $rows = $("#messages tbody tr") $rows.not(selector).hide() $rows.filter(selector).show() clearSearch: -> $("#messages tbody tr").show() addMessage: (message) -> $("").attr("data-message-id", message.id.toString()) .append($("").text(message.sender or "No sender").toggleClass("blank", !message.sender)) .append($("").text((message.recipients || []).join(", ") or "No recipients").toggleClass("blank", !message.recipients.length)) .append($("").text(message.subject or "No subject").toggleClass("blank", !message.subject)) .append($("").text(@formatDate(message.created_at))) .prependTo($("#messages tbody")) @updateMessagesCount() removeMessage: (id) -> messageRow = $("""#messages tbody tr[data-message-id="#{id}"]""") isSelected = messageRow.is(".selected") if isSelected switchTo = messageRow.next().data("message-id") || messageRow.prev().data("message-id") messageRow.remove() if isSelected if switchTo @loadMessage switchTo else @unselectMessage() @updateMessagesCount() clearMessages: -> $("#messages tbody tr").remove() @unselectMessage() @updateMessagesCount() scrollToRow: (row) -> relativePosition = row.offset().top - $("#messages").offset().top if relativePosition < 0 $("#messages").scrollTop($("#messages").scrollTop() + relativePosition - 20) else overflow = relativePosition + row.height() - $("#messages").height() if overflow > 0 $("#messages").scrollTop($("#messages").scrollTop() + overflow + 20) unselectMessage: -> $("#messages tbody, #message .metadata dd").empty() $("#message .metadata .attachments").hide() $("#message iframe").attr("src", "about:blank") null loadMessage: (id) -> id = id.id if id?.id? id ||= $("#messages tr.selected").attr "data-message-id" if id? $("#messages tbody tr:not([data-message-id='#{id}'])").removeClass("selected") messageRow = $("#messages tbody tr[data-message-id='#{id}']") messageRow.addClass("selected") @scrollToRow(messageRow) $.getJSON "messages/#{id}.json", (message) => $("#message .metadata dd.created_at").text(@formatDate message.created_at) $("#message .metadata dd.from").text(message.sender) $("#message .metadata dd.to").text((message.recipients || []).join(", ")) $("#message .metadata dd.subject").text(message.subject) $("#message .views .tab.format").each (i, el) -> $el = $(el) format = $el.attr("data-message-format") if $.inArray(format, message.formats) >= 0 $el.find("a").attr("href", "messages/#{id}.#{format}") $el.show() else $el.hide() if $("#message .views .tab.selected:not(:visible)").length $("#message .views .tab.selected").removeClass("selected") $("#message .views .tab:visible:first").addClass("selected") if message.attachments.length $ul = $("