diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000000000..fc5380bfd5dab --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,38 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/ruby/.devcontainer/base.Dockerfile + +# [Choice] Ruby version: 3.4, 3.3, 3.2 +ARG VARIANT="1.1.3-3.4.4" +FROM ghcr.io/rails/devcontainer/images/ruby:${VARIANT} + +RUN sudo apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && sudo apt-get -y install --no-install-recommends \ + mariadb-client libmariadb-dev \ + postgresql-client postgresql-contrib libpq-dev \ + ffmpeg mupdf mupdf-tools libvips-dev poppler-utils \ + libxml2-dev sqlite3 imagemagick + +# Add the Rails main Gemfile and install the gems. This means the gem install can be done +# during build instead of on start. When a fork or branch has different gems, we still have an +# advantage due to caching of the other gems. +RUN mkdir -p /tmp/rails +COPY Gemfile Gemfile.lock RAILS_VERSION rails.gemspec package.json yarn.lock /tmp/rails/ +COPY actioncable/actioncable.gemspec /tmp/rails/actioncable/ +COPY actionmailbox/actionmailbox.gemspec /tmp/rails/actionmailbox/ +COPY actionmailer/actionmailer.gemspec /tmp/rails/actionmailer/ +COPY actionpack/actionpack.gemspec /tmp/rails/actionpack/ +COPY actiontext/actiontext.gemspec /tmp/rails/actiontext/ +COPY actionview/actionview.gemspec /tmp/rails/actionview/ +COPY activejob/activejob.gemspec /tmp/rails/activejob/ +COPY activemodel/activemodel.gemspec /tmp/rails/activemodel/ +COPY activerecord/activerecord.gemspec /tmp/rails/activerecord/ +COPY activestorage/activestorage.gemspec /tmp/rails/activestorage/ +COPY activesupport/activesupport.gemspec /tmp/rails/activesupport/ +COPY railties/railties.gemspec /tmp/rails/railties/ +COPY tools/releaser/releaser.gemspec /tmp/rails/tools/releaser/ +# Docker does not support COPY as users other than root. So we need to chown this dir so we +# can bundle as vscode user and then remove the tmp dir +RUN sudo chown -R vscode:vscode /tmp/rails +USER vscode +RUN cd /tmp/rails \ + && /home/vscode/.rbenv/shims/bundle install \ + && rm -rf /tmp/rails diff --git a/.devcontainer/boot.sh b/.devcontainer/boot.sh new file mode 100755 index 0000000000000..ee03e3678a5e6 --- /dev/null +++ b/.devcontainer/boot.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +bundle update --bundler +bundle install + +if [ -n "${NVM_DIR}" ]; then + # shellcheck disable=SC1091 + . "${NVM_DIR}/nvm.sh" && nvm install --lts + yarn install +fi + +cd activerecord || { echo "activerecord directory doesn't exist"; exit; } + +# Create PostgreSQL databases +bundle exec rake db:postgresql:rebuild + +# Create MySQL databases +bundle exec rake db:mysql:rebuild diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml new file mode 100644 index 0000000000000..514c48d4e1179 --- /dev/null +++ b/.devcontainer/compose.yaml @@ -0,0 +1,57 @@ +services: + rails: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + + volumes: + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + depends_on: + - postgres + - mysql + - redis + - memcached + + environment: + MYSQL_CODESPACES: "1" + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + postgres: + image: postgres:latest + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + + mysql: + image: mysql:latest + restart: unless-stopped + volumes: + - mysql-data:/var/lib/mysql + environment: + MYSQL_ROOT_PASSWORD: root + + redis: + image: valkey/valkey:8 + restart: unless-stopped + volumes: + - redis-data:/data + + memcached: + image: memcached:latest + restart: unless-stopped + command: ["-m", "1024"] + +volumes: + postgres-data: + mysql-data: + redis-data: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000000..546af855e55c5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,49 @@ +// For format details, see https://containers.dev/implementors/json_reference/. +{ + "name": "Rails project development", + "dockerComposeFile": "compose.yaml", + "service": "rails", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "latest" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "latest" + }, + "ghcr.io/rails/devcontainer/features/postgres-client:1.1.1": { + "version": "17" + } + }, + + "containerEnv": { + "PGHOST": "postgres", + "PGUSER": "postgres", + "PGPASSWORD": "postgres", + "MYSQL_HOST": "mysql", + "REDIS_URL": "redis://redis/0", + "MEMCACHE_SERVERS": "memcached:11211" + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // This can be used to network with other containers or the host. + // "forwardPorts": [3000, 5432], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": ".devcontainer/boot.sh", + + // Configure tool-specific properties. + "customizations": { + "vscode": { + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "Shopify.ruby-lsp" + ] + } + } + + // Uncomment to connect as root instead. More info: https://containers.dev/implementors/json_reference/#remoteUser. + // "remoteUser": "root" +} diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000000..7fede1e72571f --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,86 @@ +# Changes that are cosmetic and do not add anything substantial +# to the stability, functionality, or testability of Rails will +# generally not be accepted. Read more about our rationale behind +# this decision: https://github.com/rails/rails/pull/13771#issuecomment-32746700 + +# normalizes indentation and whitespace across the project +80e66cc4d90bf8c15d1a5f6e3152e90147f00772 +# applies new string literal convention in * +adca8154c6ffce978a5dbc514273cceecbb15f8e +9ed740449884ba5841f756c4a5ccc0bce8f19082 +92e2d16a3c75d549fcd9422a31acd3323b74abaa +78d3f84955bccad0ab161c5f2b4c1133813161a3 +6b3719b7577ab81171bab94a0492ae383dd279fe +8b4e6bf23338e2080af537ea4f295e65a1d11388 +783763bde97bea3d0c200038453008a8cfff1e88 +69ab3eb57e8387b0dd9d672b5e8d9185395baa03 +f8477f13bfe554064bd25a57e5289b4ebaabb504 +b678eb57e93423ac8e2a0cc0b083ce556c6fb130 +b91ff557ef6f621d1b921f358fd5b8a5d9e9090e +c3e7abddfb759eecb30cd59f27103fda4c4827dc +35b3de8021e68649cac963bd82a74cc75d1f60f0 +628e51ff109334223094e30ad1365188bbd7f9c6 +4b6c68dfb810c836f87587a16353317d1a180805 +66a7cfa91045e05f134efc9ac0e226e66161e2e6 +bde6547bb6a8ddf18fb687bf20893d3dc87e0358 +93c9534c9871d4adad4bc33b5edc355672b59c61 +4c208254573c3428d82bd8744860bd86e1c78acb +18a2513729ab90b04b1c86963e7d5b9213270c81 +9617db2078e8a85c8944392c21dd748f932bbd80 +4df2b779ddfcb27761c71e00e2b241bfa06a0950 +a731125f12c5834de7eae3455fad63ea4d348034 +d66e7835bea9505f7003e5038aa19b6ea95ceea1 +e6ab70c439301d533f14b3387ee181d843a86b30 +# modernizes hash syntax in * +1607ee299d15c133b2b63dcfc840eddfba7e525b +477568ee33bee0dc5e57b9df624142296e3951a4 +5c315a8fa6296904f5e0ba8da919fc395548cf98 +d22e522179c1c90e658c3ed0e9b972ec62306209 +fa911a74e15ef34bb435812f7d9cf7324253476f +301ce2a6d11bc7a016f7ede71e3c6fd9fa0693a3 +63fff600accb41b56a3e6ac403d9b1732de3086d +5b6eb1d58b48fada298215b2cccda89f993890c3 +12a70404cd164008879e63cc320356e6afee3adc +60b67d76dc1d98e4269aac7705e9d8323eb42942 +# [Tests only] Enable Minitest/AssertPredicate rule +19f8ab2e7d60dcdfd7664d6bea3a9fae55a3618c +# Standardize nodoc comments +18707ab17fa492eb25ad2e8f9818a320dc20b823 +# Add Style/RedundantFreeze to remove redudant .freeze +aa3dcabd874a3e82e455e85a1c94a7abaac2900a +# Enable Performance/UnfreezeString cop +1b86d90136efb98c7b331a84ca163587307a49af +# Arel: rubocop -a +4c0a3d48804a363c7e9272519665a21f601b5248 +# Add more rubocop rules about whitespaces +fe1f4b2ad56f010a4e9b93d547d63a15953d9dc2 +# Add three new rubocop rules +55f9b8129a50206513264824abb44088230793c2 +# applies remaining conventions across the project +b326e82dc012d81e9698cb1f402502af1788c1e9 +# remove redundant curlies from hash arguments +411ccbdab2608c62aabdb320d52cb02d446bb39c +# Deletes trailing whitespaces (over text files only find * -type f -exec sed 's/[ \t]*$//' -i {} ;) +b95d6e84b00bd926b1118f6a820eca7a870b8c35 +b451de0d6de4df6bc66b274cec73b919f823d5ae +# Replace assert ! with assert_not +a1ac18671a90869ef81d02f2eafe8104e4eea34f +# Use respond_to test helpers +0d50cae996c51630361e8514e1f168b0c48957e1 +# Change refute to assert_not +211adb47e76b358ea15a3f756431c042ab231c23 +# Use assert_predicate and assert_not_predicate +94333a4c31bd10c1f358c538a167e6a4589bae2d +# Use assert_empty and assert_not_empty +82c39e1a0b5114e2d89a80883a41090567a83196 +# Remove extra whitespace +fda1863e1a8c120294c56482631d8254ad6125ff +# Reduce string objects by using \ instead of + or << for concatenating strings +b70fc698e157f2a768ba42efac08c08f4786b01c +# Hash Syntax to 1.9 related changes +eebb9ddf9ba559a510975c486fe59a4edc9da97d +be4a4cd38ff2c2db0f6b69bb72fb3557bd5a6e21 +d20a52930aa80d7f219465d6fc414a68b16ef2a8 +3c580182ff3c16d2247aabc85100bf8c6edb0f82 +5ad7f8ab418f0c760dbb584f7c78d94ce32e9ee3 +a2c843885470dddbad9430963190464a22167921 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000..f6276855cea01 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.rb diff=ruby +*.gemspec diff=ruby diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000000..b5ed405b02670 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +.rubocop.yml @rafaelfranca diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000000..fdefb6a67e9d9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Report an issue with Rails you've discovered +--- + +### Steps to reproduce + + + + +```ruby +# Your reproduction script goes here +``` + +### Expected behavior + + + +### Actual behavior + + + +### System configuration + +**Rails version**: + +**Ruby version**: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000..3ba13e0cec6cb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/autolabeler.yml b/.github/autolabeler.yml new file mode 100644 index 0000000000000..6eb6f354e57bc --- /dev/null +++ b/.github/autolabeler.yml @@ -0,0 +1,28 @@ +actioncable: + - "actioncable/**/*" +actionmailbox: + - "actionmailbox/**/*" +actionmailer: + - "actionmailer/**/*" +actionpack: + - "actionpack/**/*" +actiontext: + - "actiontext/**/*" +actionview: + - "actionview/**/*" +activejob: + - "activejob/**/*" +activemodel: + - "activemodel/**/*" +activerecord: + - "activerecord/**/*" +activestorage: + - "activestorage/**/*" +activesupport: + - "activesupport/**/*" +rails-ujs: + - "actionview/app/assets/javascripts/rails-ujs*/*" +railties: + - "railties/**/*" +docs: + - "guides/**/*" diff --git a/.github/no-response.yml b/.github/no-response.yml new file mode 100644 index 0000000000000..326fa84b7e740 --- /dev/null +++ b/.github/no-response.yml @@ -0,0 +1,12 @@ +# Configuration for probot-no-response - https://github.com/probot/no-response + +# Number of days of inactivity before an Issue is closed for lack of response +daysUntilClose: 14 +# Label requiring a response +responseRequiredLabel: more-information-needed +# Comment to post when closing an Issue for lack of response. Set to `false` to disable +closeComment: > + This issue has been automatically closed because there has been no follow-up + response from the original author. We currently don't have enough + information in order to take action. Please reach out if you have any additional + information that will help us move this issue forward. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000000..fd53fc45a39de --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,45 @@ + + +### Motivation / Background + + + +This Pull Request has been created because [REPLACE ME] + +### Detail + +This Pull Request changes [REPLACE ME] + +### Additional information + + + +### Checklist + +Before submitting the PR make sure the following are checked: + +* [ ] This Pull Request is related to one change. Unrelated changes should be opened in separate PRs. +* [ ] Commit message has a detailed description of what changed and why. If this PR fixes a related issue include it in the commit message. Ex: `[Fix #issue-number]` +* [ ] Tests are added or updated if you fix a bug or add a feature. +* [ ] CHANGELOG files are updated for the changed libraries if there is a behavior change or additional feature. Minor bug fixes and documentation changes should not be included. diff --git a/.github/security.md b/.github/security.md new file mode 100644 index 0000000000000..dc32fb3a0da2e --- /dev/null +++ b/.github/security.md @@ -0,0 +1,12 @@ +# Security Policy + +## Reporting a Vulnerability + +**Do not open up a GitHub issue if the bug is a security vulnerability in Rails**. +Instead, refer to our [security policy](https://rubyonrails.org/security). + +## Supported Versions + +Security backports are provided for some previous release series. For details +of which release series are currently receiving security backports see our +[security policy](https://rubyonrails.org/security). diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000000000..2b40308582bee --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,30 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 90 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security + - With reproduction steps + - attached PR + - regression + - release blocker +# Issues on a milestone will never be considered stale +exemptMilestones: true +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not been commented on for at least three months. + + The resources of the Rails team are limited, and so we are asking for your help. + + If you can still reproduce this error on the `8-0-stable` branch or on `main`, + please reply with all of the information you have about it in order to keep the issue open. + + Thank you for all your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false +# Limit to only `issues` or `pulls` +only: issues diff --git a/.github/verba-sequentur.yml b/.github/verba-sequentur.yml new file mode 100644 index 0000000000000..0fc1a9586a9a4 --- /dev/null +++ b/.github/verba-sequentur.yml @@ -0,0 +1,21 @@ +# Documentation: https://github.com/jonathanhefner/verba-sequentur + +"support request": + comment: > + This appears to be a request for technical support. We reserve the + issue tracker for issues only. For technical support questions, + please use the [rubyonrails-talk](https://discuss.rubyonrails.org/c/rubyonrails-talk/7) + forum or [Stack Overflow](https://stackoverflow.com/questions/tagged/ruby-on-rails), + where a wider community can help you. + close: true + +"feature request": + comment: > + This appears to be a feature request. We generally do not take + feature requests, and we reserve the issue tracker for issues only. + We recommend you [try to implement the feature]( + https://guides.rubyonrails.org/contributing_to_ruby_on_rails.html#contributing-to-the-rails-code), + and send us a pull request instead. If you are unsure if the feature + would be accepted, please ask on the [rubyonrails-core]( + https://discuss.rubyonrails.org/c/rubyonrails-core/5) forum. + close: true diff --git a/.github/workflows/devcontainer-shellcheck.yml b/.github/workflows/devcontainer-shellcheck.yml new file mode 100644 index 0000000000000..57eb1c3aa8ca4 --- /dev/null +++ b/.github/workflows/devcontainer-shellcheck.yml @@ -0,0 +1,24 @@ +name: Devcontainer Shellcheck + +on: + pull_request: + paths: + - ".devcontainer/**/*.sh" + push: + paths: + - ".devcontainer/**/*.sh" + +permissions: + contents: read + +jobs: + devcontainer_shellcheck: + name: Devcontainer Shellcheck + runs-on: ubuntu-latest + steps: + - name: Checkout (GitHub) + uses: actions/checkout@v4 + + - name: Lint Devcontainer Scripts + run: | + find .devcontainer/ -name '*.sh' -print0 | xargs -0 shellcheck diff --git a/.github/workflows/devcontainer-smoke-test.yml b/.github/workflows/devcontainer-smoke-test.yml new file mode 100644 index 0000000000000..ae5c1a2e58b7c --- /dev/null +++ b/.github/workflows/devcontainer-smoke-test.yml @@ -0,0 +1,99 @@ +name: Devcontainer smoke test + +on: push + +jobs: + build: + name: Devcontainer smoke test + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + + steps: + - name: Checkout (GitHub) + uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4 + bundler-cache: true + + - name: Generate rails app sqlite3 + run: bundle exec railties/exe/rails new myapp_sqlite --database="sqlite3" --dev --devcontainer + + - name: Test devcontainer sqlite3 + uses: devcontainers/ci@v0.3 + with: + subFolder: myapp_sqlite + imageName: ghcr.io/rails/smoke-test-devcontainer + cacheFrom: ghcr.io/rails/smoke-test-devcontainer + imageTag: sqlite3 + push: ${{ github.repository == 'rails/rails' && 'filter' || 'never' }} + refFilterForPush: refs/heads/main + runCmd: bin/rails g scaffold Post && bin/rails db:migrate && bin/rails test + + - name: Stop all containers + run: docker ps -q | xargs docker stop + + - name: Generate rails app postgresql + run: bundle exec railties/exe/rails new myapp_postgresql --database="postgresql" --dev --devcontainer + + - name: Test devcontainer postgresql + uses: devcontainers/ci@v0.3 + with: + subFolder: myapp_postgresql + imageName: ghcr.io/rails/smoke-test-devcontainer + cacheFrom: ghcr.io/rails/smoke-test-devcontainer + imageTag: postgresql + push: ${{ github.repository == 'rails/rails' && 'filter' || 'never' }} + refFilterForPush: refs/heads/main + runCmd: bin/rails g scaffold Post && bin/rails db:migrate && bin/rails test && bin/rails test:system + + - name: Stop all containers + run: docker ps -q | xargs docker stop + + - name: Generate rails app mysql + run: bundle exec railties/exe/rails new myapp_mysql --database="mysql" --dev --devcontainer + + - name: Test devcontainer mysql + uses: devcontainers/ci@v0.3 + with: + subFolder: myapp_mysql + imageName: ghcr.io/rails/smoke-test-devcontainer + cacheFrom: ghcr.io/rails/smoke-test-devcontainer + imageTag: mysql + push: ${{ github.repository == 'rails/rails' && 'filter' || 'never' }} + refFilterForPush: refs/heads/main + runCmd: bin/rails g scaffold Post && bin/rails db:migrate && bin/rails test + + - name: Stop all containers + run: docker ps -q | xargs docker stop + + - name: Generate rails app trilogy + run: bundle exec railties/exe/rails new myapp_trilogy --database="trilogy" --dev --devcontainer + + - name: Test devcontainer trilogy + uses: devcontainers/ci@v0.3 + with: + subFolder: myapp_trilogy + imageName: ghcr.io/rails/smoke-test-devcontainer + cacheFrom: ghcr.io/rails/smoke-test-devcontainer + imageTag: trilogy + push: ${{ github.repository == 'rails/rails' && 'filter' || 'never' }} + refFilterForPush: refs/heads/main + runCmd: bin/rails g scaffold Post && bin/rails db:migrate && bin/rails test + + - name: Stop all containers + run: docker ps -q | xargs docker stop diff --git a/.github/workflows/rail_inspector.yml b/.github/workflows/rail_inspector.yml new file mode 100644 index 0000000000000..9bc240995ded5 --- /dev/null +++ b/.github/workflows/rail_inspector.yml @@ -0,0 +1,27 @@ +name: Rail Inspector + +on: + pull_request: + paths: + - "tools/rail_inspector/**" + push: + paths: + - "tools/rail_inspector/**" + +permissions: + contents: read + +jobs: + rail_inspector: + name: rail_inspector tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Remove Gemfile.lock + run: rm -f Gemfile.lock + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4 + bundler-cache: true + - run: cd tools/rail_inspector && bundle exec rake diff --git a/.github/workflows/rails-new-docker.yml b/.github/workflows/rails-new-docker.yml new file mode 100644 index 0000000000000..8b36f0897085c --- /dev/null +++ b/.github/workflows/rails-new-docker.yml @@ -0,0 +1,49 @@ +name: rails-new-docker + +on: [push, pull_request] + +permissions: + contents: read + +env: + APP_NAME: devrails + APP_PATH: dev/devrails + BUNDLE_WITHOUT: db:job:cable:storage + +jobs: + rails-new-docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Remove Gemfile.lock + run: rm -f Gemfile.lock + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4 + bundler-cache: true + - name: Generate --dev app + run: | + bundle exec railties/exe/rails new $APP_PATH --dev + - name: Build image + run: | + podman build -t $APP_NAME \ + -v $(pwd):$(pwd) \ + -f ./$APP_PATH/Dockerfile \ + ./$APP_PATH + - name: Run container + run: | + podman run --name $APP_NAME \ + -v $(pwd):$(pwd) \ + -e SECRET_KEY_BASE_DUMMY=1 \ + -e DATABASE_URL=sqlite3:storage/production.sqlite3 \ + -p 3000:3000 $APP_NAME & + - name: Test container + run: ruby -r ./.github/workflows/scripts/test-container.rb + + - uses: zzak/action-discord@v8 + continue-on-error: true + if: failure() && github.ref_name == 'main' + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + webhook: ${{ secrets.DISCORD_WEBHOOK }} diff --git a/.github/workflows/rails_releaser_tests.yml b/.github/workflows/rails_releaser_tests.yml new file mode 100644 index 0000000000000..f3c2a0f13bb47 --- /dev/null +++ b/.github/workflows/rails_releaser_tests.yml @@ -0,0 +1,29 @@ +name: Rails releaser tests + +on: + pull_request: + paths: + - "tools/releaser/**" + push: + paths: + - "tools/releaser/**" + +permissions: + contents: read + +jobs: + releaser_tests: + name: releaser tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ruby + - name: Bundle install + run: bundle install + working-directory: tools/releaser + - run: bundle exec rake + working-directory: tools/releaser diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000000..e5c84b75eda69 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: Release + +on: + release: + types: [published] + +jobs: + release: + permissions: + contents: write + id-token: write + + environment: release + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ruby + - uses: actions/setup-node@v4 + with: + node-version: lts/* + registry-url: 'https://registry.npmjs.org/' + - name: Configure trusted publishing credentials + uses: rubygems/configure-rubygems-credentials@v1.0.0 + - name: Bundle install + run: bundle install + working-directory: tools/releaser + - name: Run release rake task + run: bundle exec rake push + shell: bash + working-directory: tools/releaser + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Wait for release to propagate + run: gem exec rubygems-await pkg/*.gem + shell: bash diff --git a/.github/workflows/scripts/test-container.rb b/.github/workflows/scripts/test-container.rb new file mode 100644 index 0000000000000..1ebecd2aa3c21 --- /dev/null +++ b/.github/workflows/scripts/test-container.rb @@ -0,0 +1,29 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Based on Sam Ruby's system test from dockerfile-rails: +# https://github.com/rubys/dockerfile-rails/pull/21 + +require "net/http" +require "socket" + +LOCALHOST = Socket.gethostname +PORT = 3000 + +60.times do |i| + sleep 0.5 + begin + response = Net::HTTP.get_response(LOCALHOST, "/up", PORT) + + if %w(404 500).include? response.code + status = response.code.to_i + end + + puts response.body + exit status || 0 + rescue SystemCallError, IOError + puts "#{i}/60 Connection to #{LOCALHOST}:#{PORT} refused or timed out, retrying..." + end +end + +exit 999 diff --git a/.gitignore b/.gitignore index 52a431867ccb1..91ec62d2f9a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,21 @@ -# Don't put *.swp, *.bak, etc here; those belong in a global ~/.gitignore. -# Check out http://help.github.com/ignore-files/ for how to set that up. +# Don't put *.swp, *.bak, etc here; those belong in a global .gitignore. +# Check out https://help.github.com/articles/ignoring-files for how to set that up. -debug.log .Gemfile +.ruby-version +/*/doc/ +/*/test/tmp/ /.bundle -/.rbenv-version -/.rvmrc -/Gemfile.lock -/pkg -/dist -/doc/rdoc -/*/doc -/*/test/tmp -/activerecord/sqlnet.log -/activemodel/test/fixtures/fixture_database.sqlite3 -/activesupport/test/fixtures/isolation_test -/railties/test/500.html -/railties/test/fixtures/tmp -/railties/test/initializer/root/log -/railties/doc -/railties/guides/output -/railties/tmp -/RDOC_MAIN.rdoc +/dist/ +/doc/ +/guides/output/ +/preview/ +preview.tar.gz +Brewfile.lock.json +debug.log* +node_modules/ +package-lock.json +pkg/ +/tmp/ +/yarn-error.log +/test-reports/ diff --git a/.mdlrc b/.mdlrc new file mode 100644 index 0000000000000..4e92d0f58cb91 --- /dev/null +++ b/.mdlrc @@ -0,0 +1 @@ +style "#{File.dirname(__FILE__)}/.mdlrc.rb" \ No newline at end of file diff --git a/.mdlrc.rb b/.mdlrc.rb new file mode 100644 index 0000000000000..78d01e5ccde72 --- /dev/null +++ b/.mdlrc.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +all + +exclude_rule "MD003" +exclude_rule "MD004" +exclude_rule "MD005" +exclude_rule "MD006" +exclude_rule "MD007" +exclude_rule "MD012" +exclude_rule "MD014" +exclude_rule "MD024" +exclude_rule "MD026" +exclude_rule "MD033" +exclude_rule "MD034" +exclude_rule "MD036" +exclude_rule "MD040" +exclude_rule "MD041" + +rule "MD013", line_length: 2000, ignore_code_blocks: true +# rule "MD024", allow_different_nesting: true # This did not work as intended, see action_cable_overview.md +rule "MD029", style: :ordered +# rule "MD046", style: :consistent # default (:fenced) diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000000000..a1bae07239aa7 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,410 @@ +plugins: + - rubocop-minitest + - rubocop-packaging + - rubocop-performance + - rubocop-rails + - rubocop-md + +AllCops: + # RuboCop has a bunch of cops enabled by default. This setting tells RuboCop + # to ignore them, so only the ones explicitly set in this file are enabled. + DisabledByDefault: true + SuggestExtensions: false + Exclude: + - '**/tmp/**/*' + - '**/templates/**/*' + - '**/vendor/**/*' + - 'actionmailbox/test/dummy/**/*' + - 'activestorage/test/dummy/**/*' + - 'actiontext/test/dummy/**/*' + - 'tools/rail_inspector/test/fixtures/*' + - guides/source/debugging_rails_applications.md + - guides/source/active_support_instrumentation.md + - '**/node_modules/**/*' + - '**/CHANGELOG.md' + - '**/2_*_release_notes.md' + - '**/3_*_release_notes.md' + - '**/4_*_release_notes.md' + - '**/5_*_release_notes.md' + - '**/6_*_release_notes.md' + + +Performance: + Exclude: + - '**/test/**/*' + +# Prefer assert_not over assert ! +Rails/AssertNot: + Include: + - '**/test/**/*' + +# Prefer assert_not_x over refute_x +Rails/RefuteMethods: + Include: + - '**/test/**/*' + +Rails/IndexBy: + Enabled: true + +Rails/IndexWith: + Enabled: true + +# Prefer &&/|| over and/or. +Style/AndOr: + Enabled: true + +Layout/ClosingHeredocIndentation: + Enabled: true + +Layout/ClosingParenthesisIndentation: + Enabled: true + +# Align comments with method definitions. +Layout/CommentIndentation: + Enabled: true + +Layout/DefEndAlignment: + Enabled: true + +Layout/ElseAlignment: + Enabled: true + +# Align `end` with the matching keyword or starting expression except for +# assignments, where it should be aligned with the LHS. +Layout/EndAlignment: + Enabled: true + EnforcedStyleAlignWith: variable + AutoCorrect: true + +Layout/EndOfLine: + Enabled: true + +Layout/EmptyLineAfterMagicComment: + Enabled: true + +Layout/EmptyLinesAroundAccessModifier: + Enabled: true + EnforcedStyle: only_before + +Layout/EmptyLinesAroundBlockBody: + Enabled: true + +# In a regular class definition, no empty lines around the body. +Layout/EmptyLinesAroundClassBody: + Enabled: true + +# In a regular method definition, no empty lines around the body. +Layout/EmptyLinesAroundMethodBody: + Enabled: true + +# In a regular module definition, no empty lines around the body. +Layout/EmptyLinesAroundModuleBody: + Enabled: true + +# Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }. +Style/HashSyntax: + Enabled: true + EnforcedShorthandSyntax: either + +# Method definitions after `private` or `protected` isolated calls need one +# extra level of indentation. +Layout/IndentationConsistency: + Enabled: true + EnforcedStyle: indented_internal_methods + Exclude: + - '**/*.md' + +# Two spaces, no tabs (for indentation). +Layout/IndentationWidth: + Enabled: true + +Layout/LeadingCommentSpace: + Enabled: true + +Layout/SpaceAfterColon: + Enabled: true + +Layout/SpaceAfterComma: + Enabled: true + +Layout/SpaceAfterSemicolon: + Enabled: true + +Layout/SpaceAroundEqualsInParameterDefault: + Enabled: true + +Layout/SpaceAroundKeyword: + Enabled: true + +Layout/SpaceAroundOperators: + Enabled: true + +Layout/SpaceBeforeComma: + Enabled: true + +Layout/SpaceBeforeComment: + Enabled: true + +Layout/SpaceBeforeFirstArg: + Enabled: true + +Style/DefWithParentheses: + Enabled: true + +# Defining a method with parameters needs parentheses. +Style/MethodDefParentheses: + Enabled: true + +Style/ExplicitBlockArgument: + Enabled: true + +Style/FrozenStringLiteralComment: + Enabled: true + EnforcedStyle: always + Exclude: + - 'actionview/test/**/*.builder' + - 'actionview/test/**/*.ruby' + - 'actionpack/test/**/*.builder' + - 'actionpack/test/**/*.ruby' + - 'activestorage/db/migrate/**/*.rb' + - 'activestorage/db/update_migrate/**/*.rb' + - 'actionmailbox/db/migrate/**/*.rb' + - 'actiontext/db/migrate/**/*.rb' + - '**/*.md' + +Style/MapToHash: + Enabled: true + +Style/RedundantFreeze: + Enabled: true + +# Use `foo {}` not `foo{}`. +Layout/SpaceBeforeBlockBraces: + Enabled: true + +# Use `foo { bar }` not `foo {bar}`. +Layout/SpaceInsideBlockBraces: + Enabled: true + EnforcedStyleForEmptyBraces: space + +# Use `{ a: 1 }` not `{a:1}`. +Layout/SpaceInsideHashLiteralBraces: + Enabled: true + +Layout/SpaceInsideParens: + Enabled: true + +# Check quotes usage according to lint rule below. +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes + +# Detect hard tabs, no hard tabs. +Layout/IndentationStyle: + Enabled: true + +# Empty lines should not have any spaces. +Layout/TrailingEmptyLines: + Enabled: true + +# No trailing whitespace. +Layout/TrailingWhitespace: + Enabled: true + +# Use quotes for string literals when they are enough. +Style/RedundantPercentQ: + Enabled: true + +Lint/NestedMethodDefinition: + Enabled: true + +Lint/AmbiguousOperator: + Enabled: true + +Lint/AmbiguousRegexpLiteral: + Enabled: true + +Lint/Debugger: + Enabled: true + DebuggerRequires: + - debug + +Lint/DuplicateRequire: + Enabled: true + +Lint/DuplicateMagicComment: + Enabled: true + +Lint/DuplicateMethods: + Enabled: true + +Lint/ErbNewArguments: + Enabled: true + +Lint/EnsureReturn: + Enabled: true + +Lint/MissingCopEnableDirective: + Enabled: true + +# Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg. +Lint/RequireParentheses: + Enabled: true + +Lint/RedundantCopDisableDirective: + Enabled: true + +Lint/RedundantCopEnableDirective: + Enabled: true + +Lint/RedundantRequireStatement: + Enabled: true + +Lint/RedundantStringCoercion: + Enabled: true + +Lint/RedundantSafeNavigation: + Enabled: true + +Lint/UriEscapeUnescape: + Enabled: true + +Lint/UselessAssignment: + Enabled: true + +Lint/DeprecatedClassMethods: + Enabled: true + +Lint/InterpolationCheck: + Enabled: true + Exclude: + - '**/test/**/*' + +Lint/SafeNavigationChain: + Enabled: true + +Style/EvalWithLocation: + Enabled: true + Exclude: + - '**/test/**/*' + +Style/ParenthesesAroundCondition: + Enabled: true + +Style/HashTransformKeys: + Enabled: true + +Style/HashTransformValues: + Enabled: true + +Style/RedundantBegin: + Enabled: true + +Style/RedundantReturn: + Enabled: true + AllowMultipleReturnValues: true + +Style/RedundantRegexpEscape: + Enabled: true + +Style/Semicolon: + Enabled: true + AllowAsExpressionSeparator: true + +# Prefer Foo.method over Foo::method +Style/ColonMethodCall: + Enabled: true + +Style/TrivialAccessors: + Enabled: true + +# Prefer a = b || c over a = b ? b : c +Style/RedundantCondition: + Enabled: true + +Style/RedundantDoubleSplatHashBraces: + Enabled: true + +Style/OpenStructUse: + Enabled: true + +Style/ArrayIntersect: + Enabled: true + +Style/KeywordArgumentsMerging: + Enabled: true + +Performance/BindCall: + Enabled: true + +Performance/FlatMap: + Enabled: true + +Performance/MapCompact: + Enabled: true + +Performance/SelectMap: + Enabled: true + +Performance/RedundantMerge: + Enabled: true + +Performance/StartWith: + Enabled: true + +Performance/EndWith: + Enabled: true + +Performance/RegexpMatch: + Enabled: true + +Performance/ReverseEach: + Enabled: true + +Performance/StringReplacement: + Enabled: true + +Performance/DeletePrefix: + Enabled: true + +Performance/DeleteSuffix: + Enabled: true + +Performance/InefficientHashSearch: + Enabled: true + +Performance/ConstantRegexp: + Enabled: true + +Performance/RedundantStringChars: + Enabled: true + +Performance/StringInclude: + Enabled: true + +Minitest/AssertNil: + Enabled: true + +Minitest/AssertRaisesWithRegexpArgument: + Enabled: true + +Minitest/AssertWithExpectedArgument: + Enabled: true + +Minitest/LiteralAsActualArgument: + Enabled: true + +Minitest/NonExecutableTestMethod: + Enabled: true + +Minitest/SkipEnsure: + Enabled: true + +Minitest/UnreachableAssertion: + Enabled: true + +Markdown: + # Whether to run RuboCop against non-valid snippets + WarnInvalid: true + # Whether to lint codeblocks without code attributes + Autodetect: false diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 68d5c594ae2f7..0000000000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -script: 'ci/travis.rb' -rvm: - - 1.9.3 -env: - - "GEM=railties" - - "GEM=ap,am,amo,ares,as" - - "GEM=ar:mysql" - - "GEM=ar:mysql2" - - "GEM=ar:sqlite3" - - "GEM=ar:postgresql" -notifications: - email: false - irc: - on_success: change - on_failure: always - channels: - - "irc.freenode.org#rails-contrib" - campfire: - on_success: change - on_failure: always - rooms: - - secure: "CGWvthGkBKNnTnk9YSmf9AXKoiRI33fCl5D3jU4nx3cOPu6kv2R9nMjt9EAo\nOuS4Q85qNSf4VNQ2cUPNiNYSWQ+XiTfivKvDUw/QW9r1FejYyeWarMsSBWA+\n0fADjF1M2dkDIVLgYPfwoXEv7l+j654F1KLKB69F0F/netwP9CQ=" -bundler_args: --path vendor/bundle diff --git a/.yardopts b/.yardopts index 25ec38658f55c..27130107a5773 100644 --- a/.yardopts +++ b/.yardopts @@ -1,4 +1,5 @@ --exclude /templates/ --quiet act*/lib/**/*.rb +act*/app/**/*.rb railties/lib/**/*.rb diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000000000..6be74e145d786 --- /dev/null +++ b/.yarnrc @@ -0,0 +1,2 @@ +workspaces-experimental true +--add.prefer-offline true \ No newline at end of file diff --git a/Brewfile b/Brewfile new file mode 100644 index 0000000000000..f767e801d1f1b --- /dev/null +++ b/Brewfile @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +brew "ffmpeg" +brew "memcached" +brew "mysql" +brew "postgresql@16" +brew "libpq" +brew "redis" +brew "yarn" +cask "xquartz" +brew "mupdf" +brew "poppler" +brew "imagemagick" +brew "vips" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000000..d3c1555f706fa --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,11 @@ +# Contributor Code of Conduct + +The Rails team is committed to fostering a welcoming community. + +**Our Code of Conduct can be found here**: + +https://rubyonrails.org/conduct + +For a history of updates, see the page history here: + +https://github.com/rails/website/commits/main/_pages/conduct.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000000..51e1236001b8a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,62 @@ +[![Build Status](https://badge.buildkite.com/ab1152b6a1f6a61d3ea4ec5b3eece8d4c2b830998459c75352.svg?branch=main)](https://buildkite.com/rails/rails) +[![Code Triage Badge](https://www.codetriage.com/rails/rails/badges/users.svg)](https://www.codetriage.com/rails/rails) +[![Version](https://img.shields.io/gem/v/rails)](https://rubygems.org/gems/rails) +[![License](https://img.shields.io/github/license/rails/rails)](https://github.com/rails/rails) + +## How to contribute to Ruby on Rails + +#### **Did you find a bug?** + +* **Do not open up a GitHub issue if the bug is a security vulnerability + in Rails**, and instead to refer to our [security policy](https://rubyonrails.org/security). + +* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/rails/rails/issues). + +* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/rails/rails/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. + +* If possible, use the relevant bug report templates to create the issue. Simply copy the content of the appropriate template into a .rb file, make the necessary changes to demonstrate the issue, and **paste the content into the issue description**: + * [**Active Record** (models, encryption, database) issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/active_record.rb) + * [**Active Record Migrations** issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/active_record_migrations.rb) + * [**Action View** (views, helpers) issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/action_view.rb) + * [**Active Job** issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/active_job.rb) + * [**Active Storage** issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/active_storage.rb) + * [**Action Mailer** issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/action_mailer.rb) + * [**Action Mailbox** issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/action_mailbox.rb) + * [**Action Pack** (controllers, routing) issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/action_controller.rb) + * [**Generic template** for other issues](https://github.com/rails/rails/blob/main/guides/bug_report_templates/generic.rb) + +* For more detailed information on submitting a bug report and creating an issue, visit our [reporting guidelines](https://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html#reporting-an-issue). + +#### **Did you write a patch that fixes a bug?** + +* Open a new GitHub pull request with the patch. + +* Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. + +* Before submitting, please read the [Contributing to Ruby on Rails](https://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html) guide to know more about coding conventions and benchmarks. + +#### **Did you fix whitespace, format code, or make a purely cosmetic patch?** + +Changes that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of Rails will generally not be accepted (read more about [our rationales behind this decision](https://github.com/rails/rails/pull/13771#issuecomment-32746700)). + +#### **Do you intend to add a new feature or change an existing one?** + +* Suggest your change in the [rubyonrails-core mailing list](https://discuss.rubyonrails.org/c/rubyonrails-core) and start writing code. + +* Do not open an issue on GitHub until you have collected positive feedback about the change. GitHub issues are primarily intended for bug reports and fixes. + +* We generally reject changes to Active Support core extensions. Those change should be proposed in the [Ruby issue tracker instead](https://bugs.ruby-lang.org/issues), as we don't want to conflict with future versions of Ruby. + +#### **Do you have questions about the source code?** + +* Ask any question about how to use Ruby on Rails in the [rubyonrails-talk mailing list](https://discuss.rubyonrails.org/c/rubyonrails-talk). + +#### **Do you want to contribute to the Rails documentation?** + +* Please read [Contributing to the Rails Documentation](https://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html#contributing-to-the-rails-documentation). + +Ruby on Rails is a volunteer effort. We encourage you to pitch in and join [the team](https://contributors.rubyonrails.org)! + +Thanks! :heart: :heart: :heart: + +Rails Team diff --git a/Gemfile b/Gemfile index 5bd9d4a215525..6512ce889c746 100644 --- a/Gemfile +++ b/Gemfile @@ -1,94 +1,169 @@ -source 'https://rubygems.org/' +# frozen_string_literal: true +source "https://rubygems.org/" gemspec -if ENV['AREL'] - gem 'arel', :path => ENV['AREL'] -else - gem 'arel' +gem "minitest" + +# We need a newish Rake since Active Job sets its test tasks' descriptions. +gem "rake", ">= 13" + +gem "releaser", path: "tools/releaser" + +gem "sprockets-rails", ">= 2.0.0", require: false +gem "propshaft", ">= 0.1.7", "!= 1.0.1" +gem "capybara", ">= 3.39" +gem "selenium-webdriver", ">= 4.20.0" + +gem "rack-cache", "~> 1.2" +gem "stimulus-rails" +gem "turbo-rails" +gem "jsbundling-rails" +gem "cssbundling-rails" +gem "importmap-rails", ">= 1.2.3" +gem "tailwindcss-rails" +gem "dartsass-rails" +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" +gem "kamal", ">= 2.1.0", require: false +gem "thruster", require: false +# require: false so bcrypt is loaded only when has_secure_password is used. +# This is to avoid Active Model (and by extension the entire framework) +# being dependent on a binary library. +gem "bcrypt", "~> 3.1.11", require: false + +# This needs to be with require false to avoid it being automatically loaded by +# sprockets. +gem "terser", ">= 1.1.4", require: false + +# Explicitly avoid 1.x that doesn't support Ruby 2.4+ +gem "json", ">= 2.0.0", "!=2.7.0" + +# Workaround until all supported Ruby versions ship with uri version 0.13.1 or higher. +gem "uri", ">= 0.13.1", require: false + +gem "prism" + +group :rubocop do + # Rubocop has to be locked in the Gemfile because CI ignores Gemfile.lock + # We don't want rubocop to start failing whenever rubocop makes a new release. + gem "rubocop", "< 1.73", require: false + gem "rubocop-minitest", require: false + gem "rubocop-packaging", require: false + gem "rubocop-performance", require: false + gem "rubocop-rails", require: false + gem "rubocop-md", require: false + + # This gem is used in Railties tests so it must be a development dependency. + gem "rubocop-rails-omakase", require: false +end + +group :mdl do + gem "mdl", "!= 0.13.0", require: false end -gem 'rack-test', :git => "https://github.com/brynary/rack-test.git" -gem 'bcrypt-ruby', '~> 3.0.0' -gem 'jquery-rails' +group :doc do + gem "sdoc", git: "https://github.com/rails/sdoc.git", branch: "main" + gem "rdoc", "< 6.10" + gem "redcarpet", "~> 3.6.1", platforms: :ruby + gem "w3c_validators", "~> 1.3.6" + gem "rouge" + gem "rubyzip", "~> 2.0" +end -if ENV['JOURNEY'] - gem 'journey', :path => ENV['JOURNEY'] +# Active Support +gem "dalli", ">= 3.0.1" +gem "listen", "~> 3.3", require: false +gem "libxml-ruby", platforms: :ruby +gem "connection_pool", require: false +gem "rexml", require: false +gem "msgpack", ">= 1.7.0", require: false + +# for railties +gem "bootsnap", ">= 1.4.4", require: false +gem "webrick", require: false +gem "jbuilder", require: false +gem "web-console", require: false + +# Action Pack and railties +rack_version = ENV.fetch("http://github.com/RACK", "~> 3.0") +if rack_version != "head" + gem "rack", rack_version else - gem 'journey', :git => "git://github.com/rails/journey" + gem "rack", git: "https://github.com/rack/rack.git", branch: "main" end -# This needs to be with require false to avoid -# it being automatically loaded by sprockets -gem 'uglifier', '>= 1.0.3', :require => false +gem "useragent", require: false + +# Active Job +group :job do + gem "resque", require: false + gem "resque-scheduler", require: false + gem "sidekiq", "!= 8.0.3", require: false + gem "sucker_punch", require: false + gem "queue_classic", ">= 4.0.0", require: false, platforms: :ruby + gem "sneakers", require: false + gem "backburner", require: false +end -gem 'rake', '>= 0.8.7' -gem 'mocha', '>= 0.9.8' +# Action Cable +group :cable do + gem "puma", ">= 5.0.3", require: false -group :doc do - # The current sdoc cannot generate GitHub links due - # to a bug, but the PR that fixes it has been there - # for some weeks unapplied. As a temporary solution - # this is our own fork with the fix. - gem 'sdoc', :git => 'git://github.com/fxn/sdoc.git' - gem 'RedCloth', '~> 4.2' - gem 'w3c_validators' -end + gem "redis", ">= 4.0.1", require: false -# AS -gem 'memcache-client', '>= 1.8.5' + gem "redis-namespace" -# Add your own local bundler stuff -local_gemfile = File.dirname(__FILE__) + "/.Gemfile" -instance_eval File.read local_gemfile if File.exists? local_gemfile + gem "websocket-client-simple", require: false +end -platforms :mri do - group :test do - gem 'ruby-prof' - end +# Active Storage +group :storage do + gem "aws-sdk-s3", require: false + gem "google-cloud-storage", "~> 1.11", require: false + gem "azure-storage-blob", "~> 2.0", require: false + + gem "image_processing", "~> 1.2" end -platforms :ruby do - gem 'json' - gem 'yajl-ruby' - gem 'nokogiri', '>= 1.4.5' +# Action Mailbox +gem "aws-sdk-sns", require: false +gem "webmock" - # AR - gem 'sqlite3', '~> 1.3.5' +# Add your own local bundler stuff. +local_gemfile = File.expand_path(".Gemfile", __dir__) +instance_eval File.read local_gemfile if File.exist? local_gemfile - group :db do - gem 'pg', '>= 0.11.0' - gem 'mysql', '>= 2.8.1' - gem 'mysql2', '>= 0.3.10' +group :test do + gem "minitest-bisect", require: false + gem "minitest-ci", require: false + gem "minitest-retry" + + platforms :mri do + gem "stackprof" + gem "debug", ">= 1.1.0", require: false end + + # Needed for Railties tests because it is included in generated apps. + gem "brakeman" + gem "bundler-audit" end -platforms :jruby do - gem 'json' - gem 'activerecord-jdbcsqlite3-adapter', '>= 1.2.0' +platforms :ruby, :windows do + gem "nokogiri", ">= 1.8.1", "!= 1.11.0" - # This is needed by now to let tests work on JRuby - # TODO: When the JRuby guys merge jruby-openssl in - # jruby this will be removed - gem 'jruby-openssl' + # Active Record. + gem "sqlite3", ">= 2.1" group :db do - gem 'activerecord-jdbcmysql-adapter', '>= 1.2.0' - gem 'activerecord-jdbcpostgresql-adapter', '>= 1.2.0' + gem "pg", "~> 1.3" + gem "mysql2", "~> 0.5" + gem "trilogy", ">= 2.7.0" end end -# gems that are necessary for ActiveRecord tests with Oracle database -if ENV['ORACLE_ENHANCED_PATH'] || ENV['ORACLE_ENHANCED'] - platforms :ruby do - gem 'ruby-oci8', '>= 2.0.4' - end - if ENV['ORACLE_ENHANCED_PATH'] - gem 'activerecord-oracle_enhanced-adapter', :path => ENV['ORACLE_ENHANCED_PATH'] - else - gem 'activerecord-oracle_enhanced-adapter', :git => 'git://github.com/rsim/oracle-enhanced.git' - end -end +gem "tzinfo-data", platforms: [:windows, :jruby] +gem "wdm", ">= 0.1.0", platforms: [:windows] -# A gem necessary for ActiveRecord tests with IBM DB -gem 'ibm_db' if ENV['IBM_DB'] +gem "launchy" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000000000..19c5676b0971e --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,823 @@ +GIT + remote: https://github.com/rails/sdoc.git + revision: cd75e36ce2d1acb66734c1390ffe33aa05479380 + branch: main + specs: + sdoc (3.0.0.alpha) + nokogiri + rdoc (>= 5.0) + rouge + +PATH + remote: . + specs: + actioncable (8.1.0.alpha) + actionpack (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.0.alpha) + actionpack (= 8.1.0.alpha) + activejob (= 8.1.0.alpha) + activerecord (= 8.1.0.alpha) + activestorage (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + mail (>= 2.8.0) + actionmailer (8.1.0.alpha) + actionpack (= 8.1.0.alpha) + actionview (= 8.1.0.alpha) + activejob (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.0.alpha) + actionview (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.0.alpha) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.0.alpha) + activerecord (= 8.1.0.alpha) + activestorage (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.0.alpha) + activesupport (= 8.1.0.alpha) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.0.alpha) + activesupport (= 8.1.0.alpha) + globalid (>= 0.3.6) + activemodel (8.1.0.alpha) + activesupport (= 8.1.0.alpha) + activerecord (8.1.0.alpha) + activemodel (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + timeout (>= 0.4.0) + activestorage (8.1.0.alpha) + actionpack (= 8.1.0.alpha) + activejob (= 8.1.0.alpha) + activerecord (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + marcel (~> 1.0) + activesupport (8.1.0.alpha) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + rails (8.1.0.alpha) + actioncable (= 8.1.0.alpha) + actionmailbox (= 8.1.0.alpha) + actionmailer (= 8.1.0.alpha) + actionpack (= 8.1.0.alpha) + actiontext (= 8.1.0.alpha) + actionview (= 8.1.0.alpha) + activejob (= 8.1.0.alpha) + activemodel (= 8.1.0.alpha) + activerecord (= 8.1.0.alpha) + activestorage (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + bundler (>= 1.15.0) + railties (= 8.1.0.alpha) + railties (8.1.0.alpha) + actionpack (= 8.1.0.alpha) + activesupport (= 8.1.0.alpha) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) + +PATH + remote: tools/releaser + specs: + releaser (1.0.0) + minitest + rake (~> 13.0) + +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.15) + railties + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + amq-protocol (2.3.2) + ast (2.4.2) + aws-eventstream (1.3.0) + aws-partitions (1.1037.0) + aws-sdk-core (3.215.1) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.96.0) + aws-sdk-core (~> 3, >= 3.210.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.177.0) + aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sdk-sns (1.92.0) + aws-sdk-core (~> 3, >= 3.210.0) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.11.0) + aws-eventstream (~> 1, >= 1.0.2) + azure-storage-blob (2.0.3) + azure-storage-common (~> 2.0) + nokogiri (~> 1, >= 1.10.8) + azure-storage-common (2.0.4) + faraday (~> 1.0) + faraday_middleware (~> 1.0, >= 1.0.0.rc1) + net-http-persistent (~> 4.0) + nokogiri (~> 1, >= 1.10.8) + backburner (1.6.1) + beaneater (~> 1.0) + concurrent-ruby (~> 1.0, >= 1.0.1) + dante (> 0.1.5) + base64 (0.2.0) + bcrypt (3.1.20) + bcrypt_pbkdf (1.1.1) + beaneater (1.1.3) + benchmark (0.4.0) + bigdecimal (3.1.9) + bindex (0.8.1) + bootsnap (1.18.4) + msgpack (~> 1.2) + brakeman (7.0.0) + racc + builder (3.3.0) + bundler-audit (0.9.2) + bundler (>= 1.2.0, < 3) + thor (~> 1.0) + bunny (2.23.0) + amq-protocol (~> 2.3, >= 2.3.1) + sorted_set (~> 1, >= 1.0.2) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + chef-utils (18.6.2) + concurrent-ruby + childprocess (5.1.0) + logger (~> 1.5) + concurrent-ruby (1.3.4) + connection_pool (2.5.0) + crack (1.0.0) + bigdecimal + rexml + crass (1.0.6) + cssbundling-rails (1.4.1) + railties (>= 6.0.0) + dalli (3.2.8) + dante (0.2.0) + dartsass-rails (0.5.1) + railties (>= 6.0.0) + sass-embedded (~> 1.63) + date (3.4.1) + debug (1.10.0) + irb (~> 1.10) + reline (>= 0.3.8) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + dotenv (3.1.7) + drb (2.2.1) + ed25519 (1.3.0) + erubi (1.13.1) + et-orbi (1.2.11) + tzinfo + event_emitter (0.2.6) + execjs (2.10.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.1.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + ffi (1.17.1) + ffi (1.17.1-aarch64-linux-gnu) + ffi (1.17.1-aarch64-linux-musl) + ffi (1.17.1-arm-linux-gnu) + ffi (1.17.1-arm-linux-musl) + ffi (1.17.1-arm64-darwin) + ffi (1.17.1-x86_64-darwin) + ffi (1.17.1-x86_64-linux-gnu) + ffi (1.17.1-x86_64-linux-musl) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) + globalid (1.2.1) + activesupport (>= 6.1) + google-apis-core (0.15.1) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) + mini_mime (~> 1.0) + mutex_m + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + google-apis-iamcredentials_v1 (0.22.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.49.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.7.1) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.2.1) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.4.0) + google-cloud-storage (1.54.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (~> 0.13) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (~> 0.38) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + google-logging-utils (0.1.0) + google-protobuf (4.29.3) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-aarch64-linux) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-arm64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-x86_64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-x86_64-linux) + bigdecimal + rake (>= 13) + googleauth (1.12.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + hashdiff (1.1.2) + httpclient (2.9.0) + mutex_m + i18n (1.14.6) + concurrent-ruby (~> 1.0) + image_processing (1.13.0) + mini_magick (>= 4.9.5, < 5) + ruby-vips (>= 2.0.17, < 3) + importmap-rails (2.1.0) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.8.0) + irb (1.14.3) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jbuilder (2.13.0) + actionview (>= 5.0.0) + activesupport (>= 5.0.0) + jmespath (1.6.2) + jsbundling-rails (1.3.1) + railties (>= 6.0.0) + json (2.10.2) + jwt (2.10.1) + base64 + kamal (2.4.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.2) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + kramdown (2.5.1) + rexml (>= 3.3.9) + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + language_server-protocol (3.17.0.3) + launchy (3.0.1) + addressable (~> 2.8) + childprocess (~> 5.0) + libxml-ruby (5.0.4) + lint_roller (1.1.0) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + logger (1.6.5) + loofah (2.24.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.0.4) + matrix (0.4.2) + mdl (0.12.0) + kramdown (~> 2.3) + kramdown-parser-gfm (~> 1.1) + mixlib-cli (~> 2.1, >= 2.1.1) + mixlib-config (>= 2.2.1, < 4) + mixlib-shellout + mini_magick (4.13.2) + mini_mime (1.1.5) + mini_portile2 (2.8.8) + minitest (5.25.4) + minitest-bisect (1.7.0) + minitest-server (~> 1.0) + path_expander (~> 1.1) + minitest-ci (3.4.0) + minitest (>= 5.0.6) + minitest-retry (0.2.3) + minitest (>= 5.0) + minitest-server (1.0.8) + drb (~> 2.0) + minitest (~> 5.16) + mixlib-cli (2.1.8) + mixlib-config (3.0.27) + tomlrb + mixlib-shellout (3.3.4) + chef-utils + mono_logger (1.1.2) + msgpack (1.7.5) + multi_json (1.15.0) + multipart-post (2.4.1) + mustermann (3.0.3) + ruby2_keywords (~> 0.0.1) + mutex_m (0.3.0) + mysql2 (0.5.6) + net-http-persistent (4.0.5) + connection_pool (~> 2.2) + net-imap (0.5.5) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-scp (4.0.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.0) + nio4r (2.7.4) + nokogiri (1.18.1) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + nokogiri (1.18.1-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.1-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.1-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.1-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.1-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.1-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.1-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.1-x86_64-linux-musl) + racc (~> 1.4) + os (1.1.4) + ostruct (0.6.1) + parallel (1.26.3) + parser (3.3.6.0) + ast (~> 2.4.1) + racc + path_expander (1.1.3) + pg (1.5.9) + prism (1.3.0) + propshaft (1.1.0) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + railties (>= 7.0.0) + psych (5.2.5) + date + stringio + public_suffix (6.0.1) + puma (6.5.0) + nio4r (~> 2.0) + queue_classic (4.0.0) + pg (>= 1.1, < 2.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.1.8) + rack-cache (1.17.0) + rack (>= 0.4) + rack-protection (4.1.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) + rack-session (2.1.0) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rainbow (3.1.1) + rake (13.2.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rbtree (0.4.6) + rdoc (6.9.1) + psych (>= 4.0.0) + redcarpet (3.6.1) + redis (5.3.0) + redis-client (>= 0.22.0) + redis-client (0.24.0) + connection_pool + redis-namespace (1.11.0) + redis (>= 4) + regexp_parser (2.10.0) + reline (0.6.0) + io-console (~> 0.5) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + resque (2.7.0) + mono_logger (~> 1) + multi_json (~> 1.0) + redis-namespace (~> 1.6) + sinatra (>= 0.9.2) + resque-scheduler (4.11.0) + mono_logger (~> 1.0) + redis (>= 3.3) + resque (>= 1.27) + rufus-scheduler (~> 3.2, != 3.3) + retriable (3.1.2) + rexml (3.4.0) + rouge (4.5.1) + rubocop (1.72.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.38.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.38.1) + parser (>= 3.3.1.0) + rubocop-md (2.0.0) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-minitest (0.37.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-packaging (0.6.0) + lint_roller (~> 1.1.0) + rubocop (>= 1.72.1, < 2.0) + rubocop-performance (1.24.0) + lint_roller (~> 1.1) + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails (2.30.3) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails-omakase (1.0.0) + rubocop + rubocop-minitest + rubocop-performance + rubocop-rails + ruby-progressbar (1.13.0) + ruby-vips (2.2.2) + ffi (~> 1.12) + logger + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + rufus-scheduler (3.9.2) + fugit (~> 1.1, >= 1.11.1) + sass-embedded (1.83.4) + google-protobuf (~> 4.29) + rake (>= 13) + sass-embedded (1.83.4-aarch64-linux-gnu) + google-protobuf (~> 4.29) + sass-embedded (1.83.4-aarch64-linux-musl) + google-protobuf (~> 4.29) + sass-embedded (1.83.4-arm-linux-gnueabihf) + google-protobuf (~> 4.29) + sass-embedded (1.83.4-arm-linux-musleabihf) + google-protobuf (~> 4.29) + sass-embedded (1.83.4-arm64-darwin) + google-protobuf (~> 4.29) + sass-embedded (1.83.4-x86_64-darwin) + google-protobuf (~> 4.29) + sass-embedded (1.83.4-x86_64-linux-gnu) + google-protobuf (~> 4.29) + sass-embedded (1.83.4-x86_64-linux-musl) + google-protobuf (~> 4.29) + securerandom (0.4.1) + selenium-webdriver (4.32.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) + serverengine (2.0.7) + sigdump (~> 0.2.2) + set (1.1.2) + sidekiq (8.0.2) + connection_pool (>= 2.5.0) + json (>= 2.9.0) + logger (>= 1.6.2) + rack (>= 3.1.0) + redis-client (>= 0.23.2) + sigdump (0.2.5) + signet (0.19.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + sinatra (4.1.1) + logger (>= 1.6.0) + mustermann (~> 3.0) + rack (>= 3.0.0, < 4) + rack-protection (= 4.1.1) + rack-session (>= 2.0.0, < 3) + tilt (~> 2.0) + sneakers (2.11.0) + bunny (~> 2.12) + concurrent-ruby (~> 1.0) + rake + serverengine (~> 2.0.5) + thor + solid_cable (3.0.5) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.6) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.1.2) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11.0) + railties (>= 7.1) + thor (~> 1.3.1) + sorted_set (1.0.3) + rbtree + set (~> 1.0) + sprockets (4.2.1) + concurrent-ruby (~> 1.0) + rack (>= 2.2.4, < 4) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) + sprockets (>= 3.0.0) + sqlite3 (2.5.0) + mini_portile2 (~> 2.8.0) + sqlite3 (2.5.0-aarch64-linux-gnu) + sqlite3 (2.5.0-aarch64-linux-musl) + sqlite3 (2.5.0-arm-linux-gnu) + sqlite3 (2.5.0-arm-linux-musl) + sqlite3 (2.5.0-arm64-darwin) + sqlite3 (2.5.0-x86_64-darwin) + sqlite3 (2.5.0-x86_64-linux-gnu) + sqlite3 (2.5.0-x86_64-linux-musl) + sshkit (1.23.2) + base64 + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stackprof (0.2.27) + stimulus-rails (1.3.4) + railties (>= 6.0.0) + stringio (3.1.7) + sucker_punch (3.2.0) + concurrent-ruby (~> 1.0) + tailwindcss-rails (3.2.0) + railties (>= 7.0.0) + tailwindcss-ruby + tailwindcss-ruby (3.4.17) + tailwindcss-ruby (3.4.17-aarch64-linux) + tailwindcss-ruby (3.4.17-arm-linux) + tailwindcss-ruby (3.4.17-arm64-darwin) + tailwindcss-ruby (3.4.17-x86_64-darwin) + tailwindcss-ruby (3.4.17-x86_64-linux) + terser (1.2.4) + execjs (>= 0.3.0, < 3) + thor (1.3.2) + thruster (0.1.10) + thruster (0.1.10-aarch64-linux) + thruster (0.1.10-arm64-darwin) + thruster (0.1.10-x86_64-darwin) + thruster (0.1.10-x86_64-linux) + tilt (2.6.0) + timeout (0.4.3) + tomlrb (2.0.3) + trailblazer-option (0.1.2) + trilogy (2.9.0) + turbo-rails (2.0.11) + actionpack (>= 6.0.0) + railties (>= 6.0.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uber (0.1.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uri (1.0.2) + useragent (0.16.11) + w3c_validators (1.3.7) + json (>= 1.8) + nokogiri (~> 1.6) + rexml (~> 3.2) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + webmock (3.25.0) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + webrick (1.9.1) + websocket (1.2.11) + websocket-client-simple (0.9.0) + base64 + event_emitter + mutex_m + websocket + websocket-driver (0.7.7) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.7.1) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + ruby + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + aws-sdk-s3 + aws-sdk-sns + azure-storage-blob (~> 2.0) + backburner + bcrypt (~> 3.1.11) + bootsnap (>= 1.4.4) + brakeman + bundler-audit + capybara (>= 3.39) + connection_pool + cssbundling-rails + dalli (>= 3.0.1) + dartsass-rails + debug (>= 1.1.0) + google-cloud-storage (~> 1.11) + image_processing (~> 1.2) + importmap-rails (>= 1.2.3) + jbuilder + jsbundling-rails + json (>= 2.0.0, != 2.7.0) + kamal (>= 2.1.0) + launchy + libxml-ruby + listen (~> 3.3) + mdl (!= 0.13.0) + minitest + minitest-bisect + minitest-ci + minitest-retry + msgpack (>= 1.7.0) + mysql2 (~> 0.5) + nokogiri (>= 1.8.1, != 1.11.0) + pg (~> 1.3) + prism + propshaft (>= 0.1.7, != 1.0.1) + puma (>= 5.0.3) + queue_classic (>= 4.0.0) + rack (~> 3.0) + rack-cache (~> 1.2) + rails! + rake (>= 13) + rdoc (< 6.10) + redcarpet (~> 3.6.1) + redis (>= 4.0.1) + redis-namespace + releaser! + resque + resque-scheduler + rexml + rouge + rubocop (< 1.73) + rubocop-md + rubocop-minitest + rubocop-packaging + rubocop-performance + rubocop-rails + rubocop-rails-omakase + rubyzip (~> 2.0) + sdoc! + selenium-webdriver (>= 4.20.0) + sidekiq (!= 8.0.3) + sneakers + solid_cable + solid_cache + solid_queue + sprockets-rails (>= 2.0.0) + sqlite3 (>= 2.1) + stackprof + stimulus-rails + sucker_punch + tailwindcss-rails + terser (>= 1.1.4) + thruster + trilogy (>= 2.7.0) + turbo-rails + tzinfo-data + uri (>= 0.13.1) + useragent + w3c_validators (~> 1.3.6) + wdm (>= 0.1.0) + web-console + webmock + webrick + websocket-client-simple + +BUNDLED WITH + 2.6.2 diff --git a/MIT-LICENSE b/MIT-LICENSE new file mode 100644 index 0000000000000..f12cfa766c555 --- /dev/null +++ b/MIT-LICENSE @@ -0,0 +1,20 @@ +Copyright (c) David Heinemeier Hansson + +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. diff --git a/RAILS_VERSION b/RAILS_VERSION index e1e048d8f0495..cd8590d8d7c72 100644 --- a/RAILS_VERSION +++ b/RAILS_VERSION @@ -1 +1 @@ -4.0.0.beta +8.1.0.alpha diff --git a/README.md b/README.md new file mode 100644 index 0000000000000..d084726abf892 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Welcome to Rails + +## What's Rails? + +Rails is a web-application framework that includes everything needed to +create database-backed web applications according to the +[Model-View-Controller (MVC)](https://en.wikipedia.org/wiki/Model-view-controller) +pattern. + +Understanding the MVC pattern is key to understanding Rails. MVC divides your +application into three layers: Model, View, and Controller, each with a specific responsibility. + +## Model layer + +The _**Model layer**_ represents the domain model (such as Account, Product, +Person, Post, etc.) and encapsulates the business logic specific to +your application. In Rails, database-backed model classes are derived from +`ActiveRecord::Base`. [Active Record](activerecord/README.rdoc) allows you to present the data from +database rows as objects and embellish these data objects with business logic +methods. +Although most Rails models are backed by a database, models can also be ordinary +Ruby classes, or Ruby classes that implement a set of interfaces as provided by +the [Active Model](activemodel/README.rdoc) module. + +## View layer + +The _**View layer**_ is composed of "templates" that are responsible for providing +appropriate representations of your application's resources. Templates can +come in a variety of formats, but most view templates are HTML with embedded +Ruby code (ERB files). Views are typically rendered to generate a controller response +or to generate the body of an email. In Rails, View generation is handled by [Action View](actionview/README.rdoc). + +## Controller layer + +The _**Controller layer**_ is responsible for handling incoming HTTP requests and +providing a suitable response. Usually, this means returning HTML, but Rails controllers +can also generate XML, JSON, PDFs, mobile-specific views, and more. Controllers load and +manipulate models, and render view templates in order to generate the appropriate HTTP response. +In Rails, incoming requests are routed by Action Dispatch to an appropriate controller, and +controller classes are derived from `ActionController::Base`. Action Dispatch and Action Controller +are bundled together in [Action Pack](actionpack/README.rdoc). + +## Frameworks and libraries + +[Active Record](activerecord/README.rdoc), [Active Model](activemodel/README.rdoc), [Action Pack](actionpack/README.rdoc), and [Action View](actionview/README.rdoc) can each be used independently outside Rails. + +In addition to that, Rails also comes with: + +- [Action Mailer](actionmailer/README.rdoc), a library to generate and send emails +- [Action Mailbox](actionmailbox/README.md), a library to receive emails within a Rails application +- [Active Job](activejob/README.md), a framework for declaring jobs and making them run on a variety of queuing backends +- [Action Cable](actioncable/README.md), a framework to integrate WebSockets with a Rails application +- [Active Storage](activestorage/README.md), a library to attach cloud and local files to Rails applications +- [Action Text](actiontext/README.md), a library to handle rich text content +- [Active Support](activesupport/README.rdoc), a collection of utility classes and standard library extensions that are useful for Rails, and may also be used independently outside Rails + +## Getting Started + +1. Install Rails at the command prompt if you haven't yet: + + ```bash + $ gem install rails + ``` + +2. At the command prompt, create a new Rails application: + + ```bash + $ rails new myapp + ``` + + where "myapp" is the application name. + +3. Change directory to `myapp` and start the web server: + + ```bash + $ cd myapp + $ bin/rails server + ``` + Run with `--help` or `-h` for options. + +4. Go to `http://localhost:3000` and you'll see the Rails bootscreen with your Rails and Ruby versions. + +5. Follow the guidelines to start developing your application. You may find + the following resources handy: + * [Getting Started with Rails](https://guides.rubyonrails.org/getting_started.html) + * [Ruby on Rails Guides](https://guides.rubyonrails.org) + * [The API Documentation](https://api.rubyonrails.org) + +## Contributing + +We encourage you to contribute to Ruby on Rails! Please check out the +[Contributing to Ruby on Rails guide](https://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html) for guidelines about how to proceed. [Join us!](https://contributors.rubyonrails.org) + +Trying to report a possible security vulnerability in Rails? Please +check out our [security policy](https://rubyonrails.org/security) for +guidelines about how to proceed. + +Everyone interacting in Rails and its sub-projects' codebases, issue trackers, chat rooms, and mailing lists is expected to follow the Rails [code of conduct](https://rubyonrails.org/conduct). + +## License + +Ruby on Rails is released under the [MIT License](https://opensource.org/licenses/MIT). diff --git a/README.rdoc b/README.rdoc deleted file mode 100644 index 78640b39aa3b0..0000000000000 --- a/README.rdoc +++ /dev/null @@ -1,78 +0,0 @@ -== Welcome to Rails - -Rails is a web-application framework that includes everything needed to create -database-backed web applications according to the {Model-View-Controller (MVC)}[http://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller] pattern. - -Understanding the MVC pattern is key to understanding Rails. MVC divides your application -into three layers, each with a specific responsibility. - -The View layer is composed of "templates" that are responsible for providing -appropriate representations of your application's resources. Templates -can come in a variety of formats, but most view templates are \HTML with embedded Ruby -code (.erb files). - -The Model layer represents your domain model (such as Account, Product, Person, Post) -and encapsulates the business logic that is specific to your application. In Rails, -database-backed model classes are derived from ActiveRecord::Base. Active Record allows -you to present the data from database rows as objects and embellish these data objects -with business logic methods. Although most Rails models are backed by a database, models -can also be ordinary Ruby classes, or Ruby classes that implement a set of interfaces as -provided by the ActiveModel module. You can read more about Active Record in its -{README}[link:/rails/rails/blob/master/activerecord/README.rdoc]. - -The Controller layer is responsible for handling incoming HTTP requests and providing a -suitable response. Usually this means returning \HTML, but Rails controllers can also -generate XML, JSON, PDFs, mobile-specific views, and more. Controllers manipulate models -and render view templates in order to generate the appropriate HTTP response. - -In Rails, the Controller and View layers are handled together by Action Pack. -These two layers are bundled in a single package due to their heavy interdependence. -This is unlike the relationship between Active Record and Action Pack which are -independent. Each of these packages can be used independently outside of Rails. You -can read more about Action Pack in its {README}[link:/rails/rails/blob/master/actionpack/README.rdoc]. - -== Getting Started - -1. Install Rails at the command prompt if you haven't yet: - - gem install rails - -2. At the command prompt, create a new Rails application: - - rails new myapp - - where "myapp" is the application name. - -3. Change directory to +myapp+ and start the web server: - - cd myapp; rails server - - Run with --help for options. - -4. Go to http://localhost:3000 and you'll see: - - "Welcome aboard: You're riding Ruby on Rails!" - -5. Follow the guidelines to start developing your application. You may find the following resources handy: - -* The README file created within your application. -* The {Getting Started with Rails}[http://guides.rubyonrails.org/getting_started.html]. -* The {Ruby on Rails Tutorial}[http://railstutorial.org/book]. -* The {Ruby on Rails Guides}[http://guides.rubyonrails.org]. -* The {API Documentation}[http://api.rubyonrails.org]. - -== Contributing - -We encourage you to contribute to Ruby on Rails! Please check out the {Contributing to Rails -guide}[http://edgeguides.rubyonrails.org/contributing_to_ruby_on_rails.html] for guidelines about how -to proceed. {Join us}[http://contributors.rubyonrails.org]! - -== Build Status {}[http://travis-ci.org/rails/rails] - -== Dependency Status {}[https://gemnasium.com/rails/rails] - -== License - -Ruby on Rails is released under the MIT license: - -* http://www.opensource.org/licenses/MIT diff --git a/RELEASING_RAILS.md b/RELEASING_RAILS.md new file mode 100644 index 0000000000000..eeb0c2ba29fa0 --- /dev/null +++ b/RELEASING_RAILS.md @@ -0,0 +1,174 @@ +# Releasing Rails + +In this document, we'll cover the steps necessary to release Rails. Each +section contains steps to take during that time before the release. The times +suggested in each header are just that: suggestions. However, they should +really be considered as minimums. + +## 10 Days before release + +Today is mostly coordination tasks. Here are the things you must do today: + +### Is the CI green? If not, make it green. (See "Fixing the CI") + +Do not release with a Red CI. You can find the CI status here: + +``` +https://buildkite.com/rails/rails +``` + +### Do we have any Git dependencies? If so, contact those authors. + +Having Git dependencies indicates that we depend on unreleased code. +Obviously Rails cannot be released when it depends on unreleased code. +Contact the authors of those particular gems and work out a release date that +suits them. + +### Announce your plans to the rest of the team on Basecamp + +Let them know of your plans to release. + +### Update each CHANGELOG. + +Many times commits are made without the CHANGELOG being updated. You should +review the commits since the last release, and fill in any missing information +for each CHANGELOG. + +You can review the commits for the 3.0.10 release like this: + +``` +[aaron@higgins rails (3-0-10)]$ git log v3.0.9.. +``` + +If you're doing a stable branch release, you should also ensure that all of +the CHANGELOG entries in the stable branch are also synced to the main +branch. + +## Day of release + +If making multiple releases. Publish them in order from oldest to newest, to +ensure that the "greatest" version also shows up in npm and GitHub Releases as +"latest". + +### Put the new version in the RAILS_VERSION file. + +Include an RC number if appropriate, e.g. `6.0.0.rc1`. + +### Build and test the gem. + +Run `rake install` to generate the gems and install them locally. You can now +use the version installed locally to generate a new app and check if everything +is working as expected. + +This will stop you from looking silly when you push an RC to rubygems.org and +then realize it is broken. + +### Check credentials for GitHub + +For GitHub run `gh auth status` to check that you are logged in (run `gh login` if not). + +The release task will sign the release tag. If you haven't got commit signing +set up, use https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work as a +guide. You can generate keys with the GPG suite from here: https://gpgtools.org. + +Run `rake prep_release` to prepare the release. This will populate the gemspecs and +npm package.json with the current RAILS_VERSION, add the header to the CHANGELOGs, +build the gems, and check if bundler can resolve the dependencies. + +You can now inspect the results in the diff and see if you are happy with the +changes. + +To release, Run `rake release`. This will commit the changes, tag it, and create a GitHub +release with the proper release notes in draft mode. + +Open the corresponding GitHub release draft and check that the release notes +are correct. If everything is fine, publish the release. + +### Publish the gems + +To publish the gems approve the [Release workflow in GitHub Actions](https://github.com/rails/rails/actions/workflows/release.yml), +that was created after the release was published. + +### Send Rails release announcements + +Write a release announcement that includes the version, changes, and links to +GitHub where people can find the specific commit list. Here are the mailing +lists where you should announce: + +* [rubyonrails-core](https://discuss.rubyonrails.org/c/rubyonrails-core) +* [rubyonrails-talk](https://discuss.rubyonrails.org/c/rubyonrails-talk) + +Use Markdown format for your announcement. Remember to ask people to report +issues with the release candidate to the rails-core mailing list. + +NOTE: For patch releases, there's a `rake announce` task to generate the release +post. It supports multiple patch releases too: + +``` +VERSIONS="5.0.5.rc1,5.1.3.rc1" rake announce +``` + +IMPORTANT: If any users experience regressions when using the release +candidate, you *must* postpone the release. Bugfix releases *should not* +break existing applications. + +### Post the announcement to the Rails blog. + +The blog at https://rubyonrails.org/blog is built from +https://github.com/rails/website. + +Create a file named like +`_posts/$(date +'%F')-Rails--have-been-released.markdown` + +Add YAML frontmatter +``` +--- +layout: post +title: 'Rails have been released!' +categories: releases +author: +published: true +date: +--- +``` + +Use the markdown generated by `rake announce` earlier as a base for the post. +Add some context for users as to the purpose of this release (bugfix/security). + +If this is a part of the latest release series, update `_data/version.yml` so +that the homepage points to the latest version. + +### Post the announcement to the Rails X account. + +## Security releases + +### Emailing the Rails security announce list + +Email the security announce list once for each vulnerability fixed. + +You can do this, or ask the security team to do it. + +Email the security reports to: + +* rubyonrails-security@googlegroups.com +* oss-security@lists.openwall.com + +Be sure to note the security fixes in your announcement along with CVE numbers +and links to each patch. Some people may not be able to upgrade right away, +so we need to give them the security fixes in patch form. + +* Blog announcements +* X announcements +* Merge the release branch to the stable branch +* Drink beer (or other cocktail) + +## Misc + +### Fixing the CI + +There are two simple steps for fixing the CI: + +1. Identify the problem +2. Fix it + +Repeat these steps until the CI is green. diff --git a/RELEASING_RAILS.rdoc b/RELEASING_RAILS.rdoc deleted file mode 100644 index 7a77f9bba291d..0000000000000 --- a/RELEASING_RAILS.rdoc +++ /dev/null @@ -1,230 +0,0 @@ -= Releasing Rails - -In this document, we'll cover the steps necessary to release Rails. Each -section contains steps to take during that time before the release. The times -suggested in each header are just that: suggestions. However, they should -really be considered as minimums. - -== 10 Days before release - -Today is mostly coordination tasks. Here are the things you must do today: - -=== Is the CI green? If not, make it green. (See "Fixing the CI") - -Do not release with a Red CI. You can find the CI status here: - - http://travis-ci.org/#!/rails/rails - -=== Is Sam Ruby happy? If not, make him happy. - -Sam Ruby keeps a test suite that makes sure the code samples in his book (Agile -Web Development with Rails) all work. These are valuable integration tests -for Rails. You can check the status of his tests here: - - http://intertwingly.net/projects/dashboard.html - -Do not release with Red AWDwR tests. - -=== Do we have any git dependencies? If so, contact those authors. - -Having git dependencies indicates that we depend on unreleased code. -Obviously rails cannot be released when it depends on unreleased code. -Contact the authors of those particular gems and work out a release date that -suits them. - -=== Contact the security team (either Koz or tenderlove) - -Let them know of your plans to release. There may be security issues to be -addressed, and that can impact your release date. - -=== Notify implementors. - -Ruby implementors have high stakes in making sure Rails works. Be kind and -give them a heads up that Rails will be released soonish. - -Send an email just giving a heads up about the upcoming release to these -lists: - -* team@jruby.org -* community@rubini.us -* rubyonrails-core@googlegroups.com - -Implementors will love you and help you. - -== 3 Days before release - -This is when you should release the release candidate. Here are your tasks -for today: - -=== Is the CI green? If not, make it green. - -=== Is Sam Ruby happy? If not, make him happy. - -=== Contact the security team. CVE emails must be sent on this day. - -=== Create a release branch. - -From the stable branch, create a release branch. For example, if you're -releasing Rails 3.0.10, do this: - - [aaron@higgins rails (3-0-stable)]$ git checkout -b 3-0-10 - Switched to a new branch '3-0-10' - [aaron@higgins rails (3-0-10)]$ - -=== Update each CHANGELOG. - -Many times commits are made without the CHANGELOG being updated. You should -review the commits since the last release, and fill in any missing information -for each CHANGELOG. - -You can review the commits for the 3.0.10 release like this: - - [aaron@higgins rails (3-0-10)]$ git log v3.0.9.. - -If you're doing a stable branch release, you should also ensure that all of -the CHANGELOG entries in the stable branch are also synced to the master -branch. - -=== Update the RAILS_VERSION file to include the RC. - -=== Build and test the gem. - -Run `rake install` to generate the gems and install them locally. Then try -generating a new app and ensure that nothing explodes. - -This will stop you from looking silly when you push an RC to rubygems.org and -then realise it is broken. - -=== Release the gem. - -IMPORTANT: Due to YAML parse problems on the rubygems.org server, it is safest -to use Ruby 1.8 when releasing. - -Run `rake release`. This will populate the gemspecs with data from -RAILS_VERSION, commit the changes, tag it, and push the gems to rubygems.org. -Here are the commands that `rake release` should use, so you can understand -what to do in case anything goes wrong: - - $ rake all:build - $ git commit -am'updating RAILS_VERSION' - $ git tag -m'tagging rc release' v3.0.10.rc1 - $ git push - $ git push --tags - $ for i in $(ls dist); do gem push $i; done - -=== Send Rails release announcements - -Write a release announcement that includes the version, changes, and links to -github where people can find the specific commit list. Here are the mailing -lists where you should announce: - -* rubyonrails-core@googlegroups.com -* rubyonrails-talk@googlegroups.com -* ruby-talk@ruby-lang.org - -Use markdown format for your announcement. Remember to ask people to report -issues with the release candidate to the rails-core mailing list. - -IMPORTANT: If any users experience regressions when using the release -candidate, you *must* postpone the release. Bugfix releases *should not* -break existing applications. - -=== Post the announcement to the Rails blog. - -If you used markdown format for your email, you can just paste it in to the -blog. - -* http://weblog.rubyonrails.org - -=== Post the announcement to the Rails twitter account. - -== Time between release candidate and actual release - -Check the rails-core mailing list and the github issue list for regressions in -the RC. - -If any regressions are found, fix the regressions and repeat the release -candidate process. We will not release the final until 72 hours after the -last release candidate has been pushed. This means that if users find -regressions, the scheduled release date must be postponed. - -When you fix the regressions, do not create a new branch. Fix them on the -stable branch, then cherry pick the commit to your release branch. No other -commits should be added to the release branch besides regression fixing commits. - -== Day of release - -Many of these steps are the same as for the release candidate, so if you need -more explanation on a particular step, so the RC steps. - -Today, do this stuff in this order: - -* Apply security patches to the release branch -* Update CHANGELOG with security fixes. -* Update RAILS_VERSION to remove the rc -* Build and test the gem -* Release the gems -* Email security lists -* Email general announcement lists - -=== Emailing the rails security announce list - -Email the security announce list once for each vulnerability fixed. - -You can do this, or ask the security team to do it. - -Email the security reports to: - -* rubyonrails-security@googlegroups.com -* linux-distros@vs.openwall.org - -Be sure to note the security fixes in your announcement along with CVE numbers -and links to each patch. Some people may not be able to upgrade right away, -so we need to give them the security fixes in patch form. - -* Blog announcements -* Twitter announcements -* Merge the release branch to the stable branch. -* Drink beer (or other cocktail) - -== Misc - -=== Fixing the CI - -There are two simple steps for fixing the CI: - -1. Identify the problem -2. Fix it - -Repeat these steps until the CI is green. - -=== Manually trigger docs generation - -We have a post-receive hook in GitHub that calls the docs server on pushes. -It triggers generation and publication of edge docs, updates the contrib app, -and generates and publishes stable docs if a new stable tag is detected. - -The hook unfortunately is not invoked by tag pushing, so once the new stable -tag has been pushed to origin, please run - - rake publish_docs - -You should see something like this: - - Rails master hook tasks scheduled: - - * updates the local checkout - * updates Rails Contributors - * generates and publishes edge docs - - If a new stable tag is detected it also - - * generates and publishes stable docs - - This needs typically a few minutes. - -Note you do not need to specify the tag, the docs server figures it out. - -Also, don't worry if you call that multiple times or the hook is triggered -again by some immediate regular push, if the scripts are running new calls -are just queued (in a queue of size 1). diff --git a/Rakefile b/Rakefile old mode 100755 new mode 100644 index 03b8a952c3a93..a99fdeb5144da --- a/Rakefile +++ b/Rakefile @@ -1,180 +1,69 @@ -#!/usr/bin/env rake +# frozen_string_literal: true -require 'rdoc/task' -require 'sdoc' -require 'net/http' +require "net/http" -$:.unshift File.expand_path('..', __FILE__) +$:.unshift __dir__ require "tasks/release" +require "railties/lib/rails/api/task" +require "tools/preview_docs" -desc "Build gem files for all projects" -task :build => "all:build" +desc "Run all tests by default" +task default: %w(test test:isolated) -desc "Release all gems to gemcutter and create a tag" -task :release => "all:release" - -PROJECTS = %w(activesupport activemodel actionpack actionmailer activeresource activerecord railties) - -desc 'Run all tests by default' -task :default => %w(test test:isolated) - -%w(test test:isolated package gem).each do |task_name| +%w(test test:isolated).each do |task_name| desc "Run #{task_name} task for all projects" task task_name do errors = [] - PROJECTS.each do |project| - system(%(cd #{project} && #{$0} #{task_name})) || errors << project + Releaser::FRAMEWORKS.each do |project| + system(%(cd #{project} && #{$0} #{task_name} --trace)) || errors << project end fail("Errors in #{errors.join(', ')}") unless errors.empty? end end desc "Smoke-test all projects" -task :smoke do - (PROJECTS - %w(activerecord)).each do |project| - system %(cd #{project} && #{$0} test:isolated) - end - system %(cd activerecord && #{$0} sqlite3:isolated_test) -end - -desc "Install gems for all projects." -task :install => :gem do - version = File.read("RAILS_VERSION").strip - (PROJECTS - ["railties"]).each do |project| - puts "INSTALLING #{project}" - system("gem install #{project}/pkg/#{project}-#{version}.gem --no-ri --no-rdoc") +task :smoke, [:frameworks, :isolated] do |task, args| + frameworks = args[:frameworks] ? args[:frameworks].split(" ") : Releaser::FRAMEWORKS + # The arguments are positional, and users may want to specify only the isolated flag.. so we allow 'all' as a default for the first argument: + if frameworks.include?("all") + frameworks = Releaser::FRAMEWORKS end - system("gem install railties/pkg/railties-#{version}.gem --no-ri --no-rdoc") - system("gem install pkg/rails-#{version}.gem --no-ri --no-rdoc") -end -desc "Generate documentation for the Rails framework" -RDoc::Task.new do |rdoc| - RDOC_MAIN = 'RDOC_MAIN.rdoc' + isolated = args[:isolated].nil? ? true : args[:isolated] == "true" + test_task = isolated ? "test:isolated" : "test" - # This is a hack. - # - # Backslashes are needed to prevent RDoc from autolinking "Rails" to the - # documentation of the Rails module. On the other hand, as of this - # writing README.rdoc is displayed in the front page of the project in - # GitHub, where backslashes are shown and look weird. - # - # The temporary solution is to have a README.rdoc without backslashes for - # GitHub, and gsub it to generate the main page of the API. - # - # Also, relative links in GitHub have to point to blobs, whereas in the API - # they need to point to files. - # - # The idea for the future is to have totally different files, since the - # API is no longer a generic entry point to Rails and deserves a - # dedicated main page specifically thought as an API entry point. - rdoc.before_running_rdoc do - rdoc_main = File.read('README.rdoc') - - # The ^(?=\S) assertion prevents code blocks from being processed, - # since no autolinking happens there and RDoc displays the backslash - # otherwise. - rdoc_main.gsub!(/^(?=\S).*?\b(?=Rails)\b/) { "#$&\\" } - rdoc_main.gsub!(%r{link:/rails/rails/blob/master/(\w+)/README\.rdoc}, "link:files/\\1/README_rdoc.html") - - # Remove Travis and Gemnasium status images from API pages. Only GitHub - # README page gets these images. Travis' https build image is used to avoid - # GitHub caching: http://about.travis-ci.org/docs/user/status-images - rdoc_main.gsub!(%r{^== (Build|Dependency) Status.*}, '') - - File.open(RDOC_MAIN, 'w') do |f| - f.write(rdoc_main) - end - - rdoc.rdoc_files.include(RDOC_MAIN) + (frameworks - ["activerecord"]).each do |project| + system %(cd #{project} && #{$0} #{test_task} --trace) end - rdoc.rdoc_dir = 'doc/rdoc' - rdoc.title = "Ruby on Rails Documentation" - - rdoc.options << '-f' << 'sdoc' - rdoc.options << '-T' << 'rails' - rdoc.options << '-e' << 'UTF-8' - rdoc.options << '-g' # SDoc flag, link methods to GitHub - rdoc.options << '-m' << RDOC_MAIN - - rdoc.rdoc_files.include('railties/CHANGELOG.md') - rdoc.rdoc_files.include('railties/MIT-LICENSE') - rdoc.rdoc_files.include('railties/README.rdoc') - rdoc.rdoc_files.include('railties/lib/**/*.rb') - rdoc.rdoc_files.exclude('railties/lib/rails/generators/**/templates/**/*.rb') - - rdoc.rdoc_files.include('activerecord/README.rdoc') - rdoc.rdoc_files.include('activerecord/CHANGELOG.md') - rdoc.rdoc_files.include('activerecord/lib/active_record/**/*.rb') - rdoc.rdoc_files.exclude('activerecord/lib/active_record/vendor/*') - - rdoc.rdoc_files.include('activeresource/README.rdoc') - rdoc.rdoc_files.include('activeresource/CHANGELOG.md') - rdoc.rdoc_files.include('activeresource/lib/active_resource.rb') - rdoc.rdoc_files.include('activeresource/lib/active_resource/*') - - rdoc.rdoc_files.include('actionpack/README.rdoc') - rdoc.rdoc_files.include('actionpack/CHANGELOG.md') - rdoc.rdoc_files.include('actionpack/lib/abstract_controller/**/*.rb') - rdoc.rdoc_files.include('actionpack/lib/action_controller/**/*.rb') - rdoc.rdoc_files.include('actionpack/lib/action_dispatch/**/*.rb') - rdoc.rdoc_files.include('actionpack/lib/action_view/**/*.rb') - rdoc.rdoc_files.exclude('actionpack/lib/action_controller/vendor/*') - - rdoc.rdoc_files.include('actionmailer/README.rdoc') - rdoc.rdoc_files.include('actionmailer/CHANGELOG.md') - rdoc.rdoc_files.include('actionmailer/lib/action_mailer/base.rb') - rdoc.rdoc_files.include('actionmailer/lib/action_mailer/mail_helper.rb') - rdoc.rdoc_files.exclude('actionmailer/lib/action_mailer/vendor/*') - - rdoc.rdoc_files.include('activesupport/README.rdoc') - rdoc.rdoc_files.include('activesupport/CHANGELOG.md') - rdoc.rdoc_files.include('activesupport/lib/active_support/**/*.rb') - rdoc.rdoc_files.exclude('activesupport/lib/active_support/vendor/*') - - rdoc.rdoc_files.include('activemodel/README.rdoc') - rdoc.rdoc_files.include('activemodel/CHANGELOG.md') - rdoc.rdoc_files.include('activemodel/lib/active_model/**/*.rb') + if frameworks.include? "activerecord" + test_task = isolated ? "sqlite3:isolated_test" : "sqlite3:test" + system %(cd activerecord && #{$0} #{test_task} --trace) + end end -# Enhance rdoc task to copy referenced images also -task :rdoc do - FileUtils.mkdir_p "doc/rdoc/files/examples/" - FileUtils.copy "activerecord/examples/associations.png", "doc/rdoc/files/examples/associations.png" +desc "Generate documentation for the Rails framework" +if ENV["EDGE"] + Rails::API::EdgeTask.new("rdoc") +else + Rails::API::StableTask.new("rdoc") end -desc 'Bump all versions to match version.rb' -task :update_versions do - require File.dirname(__FILE__) + "/version" +desc "Generate documentation for previewing" +task :preview_docs do + FileUtils.mkdir_p("preview") + PreviewDocs.new.render("preview") - File.open("RAILS_VERSION", "w") do |f| - f.write Rails::VERSION::STRING + "\n" - end - - constants = { - "activesupport" => "ActiveSupport", - "activemodel" => "ActiveModel", - "actionpack" => "ActionPack", - "actionmailer" => "ActionMailer", - "activeresource" => "ActiveResource", - "activerecord" => "ActiveRecord", - "railties" => "Rails" - } + require "guides/rails_guides" + Rake::Task[:rdoc].invoke - version_file = File.read("version.rb") + FileUtils.mv("doc/rdoc", "preview/api") + FileUtils.mv("guides/output", "preview/guides") - PROJECTS.each do |project| - Dir["#{project}/lib/*/version.rb"].each do |file| - File.open(file, "w") do |f| - f.write version_file.gsub(/Rails/, constants[project]) - end - end - end + system("tar -czf preview.tar.gz -C preview .") end -# -# We have a webhook configured in Github that gets invoked after pushes. +# We have a webhook configured in GitHub that gets invoked after pushes. # This hook triggers the following tasks: # # * updates the local checkout @@ -183,15 +72,10 @@ end # * if there's a new stable tag, generates and publishes stable docs # # Everything is automated and you do NOT need to run this task normally. -# -# We publish a new version by tagging, and pushing a tag does not trigger -# that webhook. Stable docs would be updated by any subsequent regular -# push, but if you want that to happen right away just run this. -# -desc 'Publishes docs, run this AFTER a new stable tag has been pushed' +desc "Publishes docs, run this AFTER a new stable tag has been pushed" task :publish_docs do - Net::HTTP.new('api.rubyonrails.org', 8080).start do |http| - request = Net::HTTP::Post.new('/rails-master-hook') + Net::HTTP.new("api.rubyonrails.org", 8080).start do |http| + request = Net::HTTP::Post.new("/rails-master-hook") response = http.request(request) puts response.body end diff --git a/actioncable/.babelrc b/actioncable/.babelrc new file mode 100644 index 0000000000000..4f0c469c60e98 --- /dev/null +++ b/actioncable/.babelrc @@ -0,0 +1,8 @@ +{ + "presets": [ + ["env", { "modules": false, "loose": true } ] + ], + "plugins": [ + "external-helpers" + ] +} diff --git a/actioncable/.eslintrc b/actioncable/.eslintrc new file mode 100644 index 0000000000000..b85ef26b314cd --- /dev/null +++ b/actioncable/.eslintrc @@ -0,0 +1,20 @@ +{ + "extends": "eslint:recommended", + "rules": { + "semi": ["error", "never"], + "quotes": ["error", "double"], + "no-unused-vars": ["error", { "vars": "all", "args": "none" }], + "no-console": "off" + }, + "plugins": [ + "import" + ], + "env": { + "browser": true, + "es6": true + }, + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module" + } +} diff --git a/actioncable/.gitignore b/actioncable/.gitignore new file mode 100644 index 0000000000000..93070addd7e69 --- /dev/null +++ b/actioncable/.gitignore @@ -0,0 +1,3 @@ +/src +/test/javascript/compiled/ +/tmp/ diff --git a/actioncable/CHANGELOG.md b/actioncable/CHANGELOG.md new file mode 100644 index 0000000000000..e6cd9e82e5eb6 --- /dev/null +++ b/actioncable/CHANGELOG.md @@ -0,0 +1,5 @@ +* Allow setting nil as subscription connection identifier for Redis. + + *Nguyen Nguyen* + +Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actioncable/CHANGELOG.md) for previous changes. diff --git a/actioncable/MIT-LICENSE b/actioncable/MIT-LICENSE new file mode 100644 index 0000000000000..be1075927c6b6 --- /dev/null +++ b/actioncable/MIT-LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 37signals LLC + +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. diff --git a/actioncable/README.md b/actioncable/README.md new file mode 100644 index 0000000000000..418c55bdc3b96 --- /dev/null +++ b/actioncable/README.md @@ -0,0 +1,24 @@ +# Action Cable – Integrated WebSockets for \Rails + +Action Cable seamlessly integrates WebSockets with the rest of your \Rails application. +It allows for real-time features to be written in Ruby in the same style +and form as the rest of your \Rails application, while still being performant +and scalable. It's a full-stack offering that provides both a client-side +JavaScript framework and a server-side Ruby framework. You have access to your full +domain model written with Active Record or your ORM of choice. + +You can read more about Action Cable in the [Action Cable Overview](https://guides.rubyonrails.org/action_cable_overview.html) guide. + +## Support + +API documentation is at: + +* https://api.rubyonrails.org + +Bug reports for the Ruby on \Rails project can be filed here: + +* https://github.com/rails/rails/issues + +Feature requests should be discussed on the rails-core mailing list here: + +* https://discuss.rubyonrails.org/c/rubyonrails-core diff --git a/actioncable/Rakefile b/actioncable/Rakefile new file mode 100644 index 0000000000000..1b766a1162a0d --- /dev/null +++ b/actioncable/Rakefile @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "base64" +require "rake/testtask" +require "pathname" +require "open3" +require "action_cable" + +task default: :test + +ENV["RAILS_MINITEST_PLUGIN"] = "true" + +Rake::TestTask.new do |t| + t.libs << "test" + t.test_files = FileList["#{__dir__}/test/**/*_test.rb"] + t.warning = true + t.verbose = true + t.options = "--profile" if ENV["CI"] + t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION) +end + +namespace :test do + task :isolated do + Dir.glob("test/**/*_test.rb").all? do |file| + sh(Gem.ruby, "-w", "-Ilib:test", file) + end || raise("Failures") + end + + task :integration do + system(Hash[*Base64.decode64(ENV.fetch("http://github.com/ENCODED", "")).split(/[ =]/)], "yarn", "test") + exit($?.exitstatus) unless $?.success? + end +end + +namespace :assets do + desc "Generate ActionCable::INTERNAL JS module" + task :codegen do + require "json" + require "action_cable" + + File.open(File.join(__dir__, "app/javascript/action_cable/internal.js").to_s, "w+") do |file| + file.write("export default #{JSON.pretty_generate(ActionCable::INTERNAL)}\n") + end + end +end diff --git a/actioncable/actioncable.gemspec b/actioncable/actioncable.gemspec new file mode 100644 index 0000000000000..132362da296eb --- /dev/null +++ b/actioncable/actioncable.gemspec @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +version = File.read(File.expand_path("../RAILS_VERSION", __dir__)).strip + +Gem::Specification.new do |s| + s.platform = Gem::Platform::RUBY + s.name = "actioncable" + s.version = version + s.summary = "WebSocket framework for Rails." + s.description = "Structure many real-time application concerns into channels over a single WebSocket connection." + + s.required_ruby_version = ">= 3.2.0" + + s.license = "MIT" + + s.author = ["Pratik Naik", "David Heinemeier Hansson"] + s.email = ["pratiknaik@gmail.com", "david@loudthinking.com"] + s.homepage = "https://rubyonrails.org/" + + s.files = Dir["CHANGELOG.md", "MIT-LICENSE", "README.md", "lib/**/*", "app/assets/javascripts/*.js"] + s.require_path = "lib" + + s.metadata = { + "bug_tracker_uri" => "https://github.com/rails/rails/issues", + "changelog_uri" => "https://github.com/rails/rails/blob/v#{version}/actioncable/CHANGELOG.md", + "documentation_uri" => "https://api.rubyonrails.org/v#{version}/", + "mailing_list_uri" => "https://discuss.rubyonrails.org/c/rubyonrails-talk", + "source_code_uri" => "https://github.com/rails/rails/tree/v#{version}/actioncable", + "rubygems_mfa_required" => "true", + } + + # NOTE: Please read our dependency guidelines before updating versions: + # https://edgeguides.rubyonrails.org/security.html#dependency-management-and-cves + + s.add_dependency "activesupport", version + s.add_dependency "actionpack", version + + s.add_dependency "nio4r", "~> 2.0" + s.add_dependency "websocket-driver", ">= 0.6.1" + s.add_dependency "zeitwerk", "~> 2.6" +end diff --git a/actioncable/app/assets/javascripts/.gitattributes b/actioncable/app/assets/javascripts/.gitattributes new file mode 100644 index 0000000000000..7051e4979a08d --- /dev/null +++ b/actioncable/app/assets/javascripts/.gitattributes @@ -0,0 +1,3 @@ +actioncable.js linguist-generated +actioncable.esm.js linguist-generated +action_cable.js linguist-generated diff --git a/actioncable/app/assets/javascripts/action_cable.js b/actioncable/app/assets/javascripts/action_cable.js new file mode 100644 index 0000000000000..a8a2a22bd9def --- /dev/null +++ b/actioncable/app/assets/javascripts/action_cable.js @@ -0,0 +1,511 @@ +(function(global, factory) { + typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define([ "exports" ], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, + factory(global.ActionCable = {})); +})(this, (function(exports) { + "use strict"; + var adapters = { + logger: typeof console !== "undefined" ? console : undefined, + WebSocket: typeof WebSocket !== "undefined" ? WebSocket : undefined + }; + var logger = { + log(...messages) { + if (this.enabled) { + messages.push(Date.now()); + adapters.logger.log("[ActionCable]", ...messages); + } + } + }; + const now = () => (new Date).getTime(); + const secondsSince = time => (now() - time) / 1e3; + class ConnectionMonitor { + constructor(connection) { + this.visibilityDidChange = this.visibilityDidChange.bind(this); + this.connection = connection; + this.reconnectAttempts = 0; + } + start() { + if (!this.isRunning()) { + this.startedAt = now(); + delete this.stoppedAt; + this.startPolling(); + addEventListener("visibilitychange", this.visibilityDidChange); + logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`); + } + } + stop() { + if (this.isRunning()) { + this.stoppedAt = now(); + this.stopPolling(); + removeEventListener("visibilitychange", this.visibilityDidChange); + logger.log("ConnectionMonitor stopped"); + } + } + isRunning() { + return this.startedAt && !this.stoppedAt; + } + recordMessage() { + this.pingedAt = now(); + } + recordConnect() { + this.reconnectAttempts = 0; + delete this.disconnectedAt; + logger.log("ConnectionMonitor recorded connect"); + } + recordDisconnect() { + this.disconnectedAt = now(); + logger.log("ConnectionMonitor recorded disconnect"); + } + startPolling() { + this.stopPolling(); + this.poll(); + } + stopPolling() { + clearTimeout(this.pollTimeout); + } + poll() { + this.pollTimeout = setTimeout((() => { + this.reconnectIfStale(); + this.poll(); + }), this.getPollInterval()); + } + getPollInterval() { + const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor; + const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)); + const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate; + const jitter = jitterMax * Math.random(); + return staleThreshold * 1e3 * backoff * (1 + jitter); + } + reconnectIfStale() { + if (this.connectionIsStale()) { + logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`); + this.reconnectAttempts++; + if (this.disconnectedRecently()) { + logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`); + } else { + logger.log("ConnectionMonitor reopening"); + this.connection.reopen(); + } + } + } + get refreshedAt() { + return this.pingedAt ? this.pingedAt : this.startedAt; + } + connectionIsStale() { + return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; + } + disconnectedRecently() { + return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold; + } + visibilityDidChange() { + if (document.visibilityState === "visible") { + setTimeout((() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`); + this.connection.reopen(); + } + }), 200); + } + } + } + ConnectionMonitor.staleThreshold = 6; + ConnectionMonitor.reconnectionBackoffRate = .15; + var INTERNAL = { + message_types: { + welcome: "welcome", + disconnect: "disconnect", + ping: "ping", + confirmation: "confirm_subscription", + rejection: "reject_subscription" + }, + disconnect_reasons: { + unauthorized: "unauthorized", + invalid_request: "invalid_request", + server_restart: "server_restart", + remote: "remote" + }, + default_mount_path: "/cable", + protocols: [ "actioncable-v1-json", "actioncable-unsupported" ] + }; + const {message_types: message_types, protocols: protocols} = INTERNAL; + const supportedProtocols = protocols.slice(0, protocols.length - 1); + const indexOf = [].indexOf; + class Connection { + constructor(consumer) { + this.open = this.open.bind(this); + this.consumer = consumer; + this.subscriptions = this.consumer.subscriptions; + this.monitor = new ConnectionMonitor(this); + this.disconnected = true; + } + send(data) { + if (this.isOpen()) { + this.webSocket.send(JSON.stringify(data)); + return true; + } else { + return false; + } + } + open() { + if (this.isActive()) { + logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`); + return false; + } else { + const socketProtocols = [ ...protocols, ...this.consumer.subprotocols || [] ]; + logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${socketProtocols}`); + if (this.webSocket) { + this.uninstallEventHandlers(); + } + this.webSocket = new adapters.WebSocket(this.consumer.url, socketProtocols); + this.installEventHandlers(); + this.monitor.start(); + return true; + } + } + close({allowReconnect: allowReconnect} = { + allowReconnect: true + }) { + if (!allowReconnect) { + this.monitor.stop(); + } + if (this.isOpen()) { + return this.webSocket.close(); + } + } + reopen() { + logger.log(`Reopening WebSocket, current state is ${this.getState()}`); + if (this.isActive()) { + try { + return this.close(); + } catch (error) { + logger.log("Failed to reopen WebSocket", error); + } finally { + logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`); + setTimeout(this.open, this.constructor.reopenDelay); + } + } else { + return this.open(); + } + } + getProtocol() { + if (this.webSocket) { + return this.webSocket.protocol; + } + } + isOpen() { + return this.isState("open"); + } + isActive() { + return this.isState("open", "connecting"); + } + triedToReconnect() { + return this.monitor.reconnectAttempts > 0; + } + isProtocolSupported() { + return indexOf.call(supportedProtocols, this.getProtocol()) >= 0; + } + isState(...states) { + return indexOf.call(states, this.getState()) >= 0; + } + getState() { + if (this.webSocket) { + for (let state in adapters.WebSocket) { + if (adapters.WebSocket[state] === this.webSocket.readyState) { + return state.toLowerCase(); + } + } + } + return null; + } + installEventHandlers() { + for (let eventName in this.events) { + const handler = this.events[eventName].bind(this); + this.webSocket[`on${eventName}`] = handler; + } + } + uninstallEventHandlers() { + for (let eventName in this.events) { + this.webSocket[`on${eventName}`] = function() {}; + } + } + } + Connection.reopenDelay = 500; + Connection.prototype.events = { + message(event) { + if (!this.isProtocolSupported()) { + return; + } + const {identifier: identifier, message: message, reason: reason, reconnect: reconnect, type: type} = JSON.parse(event.data); + this.monitor.recordMessage(); + switch (type) { + case message_types.welcome: + if (this.triedToReconnect()) { + this.reconnectAttempted = true; + } + this.monitor.recordConnect(); + return this.subscriptions.reload(); + + case message_types.disconnect: + logger.log(`Disconnecting. Reason: ${reason}`); + return this.close({ + allowReconnect: reconnect + }); + + case message_types.ping: + return null; + + case message_types.confirmation: + this.subscriptions.confirmSubscription(identifier); + if (this.reconnectAttempted) { + this.reconnectAttempted = false; + return this.subscriptions.notify(identifier, "connected", { + reconnected: true + }); + } else { + return this.subscriptions.notify(identifier, "connected", { + reconnected: false + }); + } + + case message_types.rejection: + return this.subscriptions.reject(identifier); + + default: + return this.subscriptions.notify(identifier, "received", message); + } + }, + open() { + logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`); + this.disconnected = false; + if (!this.isProtocolSupported()) { + logger.log("Protocol is unsupported. Stopping monitor and disconnecting."); + return this.close({ + allowReconnect: false + }); + } + }, + close(event) { + logger.log("WebSocket onclose event"); + if (this.disconnected) { + return; + } + this.disconnected = true; + this.monitor.recordDisconnect(); + return this.subscriptions.notifyAll("disconnected", { + willAttemptReconnect: this.monitor.isRunning() + }); + }, + error() { + logger.log("WebSocket onerror event"); + } + }; + const extend = function(object, properties) { + if (properties != null) { + for (let key in properties) { + const value = properties[key]; + object[key] = value; + } + } + return object; + }; + class Subscription { + constructor(consumer, params = {}, mixin) { + this.consumer = consumer; + this.identifier = JSON.stringify(params); + extend(this, mixin); + } + perform(action, data = {}) { + data.action = action; + return this.send(data); + } + send(data) { + return this.consumer.send({ + command: "message", + identifier: this.identifier, + data: JSON.stringify(data) + }); + } + unsubscribe() { + return this.consumer.subscriptions.remove(this); + } + } + class SubscriptionGuarantor { + constructor(subscriptions) { + this.subscriptions = subscriptions; + this.pendingSubscriptions = []; + } + guarantee(subscription) { + if (this.pendingSubscriptions.indexOf(subscription) == -1) { + logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`); + this.pendingSubscriptions.push(subscription); + } else { + logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`); + } + this.startGuaranteeing(); + } + forget(subscription) { + logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`); + this.pendingSubscriptions = this.pendingSubscriptions.filter((s => s !== subscription)); + } + startGuaranteeing() { + this.stopGuaranteeing(); + this.retrySubscribing(); + } + stopGuaranteeing() { + clearTimeout(this.retryTimeout); + } + retrySubscribing() { + this.retryTimeout = setTimeout((() => { + if (this.subscriptions && typeof this.subscriptions.subscribe === "function") { + this.pendingSubscriptions.map((subscription => { + logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`); + this.subscriptions.subscribe(subscription); + })); + } + }), 500); + } + } + class Subscriptions { + constructor(consumer) { + this.consumer = consumer; + this.guarantor = new SubscriptionGuarantor(this); + this.subscriptions = []; + } + create(channelName, mixin) { + const channel = channelName; + const params = typeof channel === "object" ? channel : { + channel: channel + }; + const subscription = new Subscription(this.consumer, params, mixin); + return this.add(subscription); + } + add(subscription) { + this.subscriptions.push(subscription); + this.consumer.ensureActiveConnection(); + this.notify(subscription, "initialized"); + this.subscribe(subscription); + return subscription; + } + remove(subscription) { + this.forget(subscription); + if (!this.findAll(subscription.identifier).length) { + this.sendCommand(subscription, "unsubscribe"); + } + return subscription; + } + reject(identifier) { + return this.findAll(identifier).map((subscription => { + this.forget(subscription); + this.notify(subscription, "rejected"); + return subscription; + })); + } + forget(subscription) { + this.guarantor.forget(subscription); + this.subscriptions = this.subscriptions.filter((s => s !== subscription)); + return subscription; + } + findAll(identifier) { + return this.subscriptions.filter((s => s.identifier === identifier)); + } + reload() { + return this.subscriptions.map((subscription => this.subscribe(subscription))); + } + notifyAll(callbackName, ...args) { + return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args))); + } + notify(subscription, callbackName, ...args) { + let subscriptions; + if (typeof subscription === "string") { + subscriptions = this.findAll(subscription); + } else { + subscriptions = [ subscription ]; + } + return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined)); + } + subscribe(subscription) { + if (this.sendCommand(subscription, "subscribe")) { + this.guarantor.guarantee(subscription); + } + } + confirmSubscription(identifier) { + logger.log(`Subscription confirmed ${identifier}`); + this.findAll(identifier).map((subscription => this.guarantor.forget(subscription))); + } + sendCommand(subscription, command) { + const {identifier: identifier} = subscription; + return this.consumer.send({ + command: command, + identifier: identifier + }); + } + } + class Consumer { + constructor(url) { + this._url = url; + this.subscriptions = new Subscriptions(this); + this.connection = new Connection(this); + this.subprotocols = []; + } + get url() { + return createWebSocketURL(this._url); + } + send(data) { + return this.connection.send(data); + } + connect() { + return this.connection.open(); + } + disconnect() { + return this.connection.close({ + allowReconnect: false + }); + } + ensureActiveConnection() { + if (!this.connection.isActive()) { + return this.connection.open(); + } + } + addSubProtocol(subprotocol) { + this.subprotocols = [ ...this.subprotocols, subprotocol ]; + } + } + function createWebSocketURL(url) { + if (typeof url === "function") { + url = url(); + } + if (url && !/^wss?:/i.test(url)) { + const a = document.createElement("a"); + a.href = url; + a.href = a.href; + a.protocol = a.protocol.replace("http", "ws"); + return a.href; + } else { + return url; + } + } + function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) { + return new Consumer(url); + } + function getConfig(name) { + const element = document.head.querySelector(`meta[name='action-cable-${name}']`); + if (element) { + return element.getAttribute("content"); + } + } + console.log("DEPRECATION: action_cable.js has been renamed to actioncable.js – please update your reference before Rails 8"); + exports.Connection = Connection; + exports.ConnectionMonitor = ConnectionMonitor; + exports.Consumer = Consumer; + exports.INTERNAL = INTERNAL; + exports.Subscription = Subscription; + exports.SubscriptionGuarantor = SubscriptionGuarantor; + exports.Subscriptions = Subscriptions; + exports.adapters = adapters; + exports.createConsumer = createConsumer; + exports.createWebSocketURL = createWebSocketURL; + exports.getConfig = getConfig; + exports.logger = logger; + Object.defineProperty(exports, "__esModule", { + value: true + }); +})); diff --git a/actioncable/app/assets/javascripts/actioncable.esm.js b/actioncable/app/assets/javascripts/actioncable.esm.js new file mode 100644 index 0000000000000..18320091e55e2 --- /dev/null +++ b/actioncable/app/assets/javascripts/actioncable.esm.js @@ -0,0 +1,512 @@ +var adapters = { + logger: typeof console !== "undefined" ? console : undefined, + WebSocket: typeof WebSocket !== "undefined" ? WebSocket : undefined +}; + +var logger = { + log(...messages) { + if (this.enabled) { + messages.push(Date.now()); + adapters.logger.log("[ActionCable]", ...messages); + } + } +}; + +const now = () => (new Date).getTime(); + +const secondsSince = time => (now() - time) / 1e3; + +class ConnectionMonitor { + constructor(connection) { + this.visibilityDidChange = this.visibilityDidChange.bind(this); + this.connection = connection; + this.reconnectAttempts = 0; + } + start() { + if (!this.isRunning()) { + this.startedAt = now(); + delete this.stoppedAt; + this.startPolling(); + addEventListener("visibilitychange", this.visibilityDidChange); + logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`); + } + } + stop() { + if (this.isRunning()) { + this.stoppedAt = now(); + this.stopPolling(); + removeEventListener("visibilitychange", this.visibilityDidChange); + logger.log("ConnectionMonitor stopped"); + } + } + isRunning() { + return this.startedAt && !this.stoppedAt; + } + recordMessage() { + this.pingedAt = now(); + } + recordConnect() { + this.reconnectAttempts = 0; + delete this.disconnectedAt; + logger.log("ConnectionMonitor recorded connect"); + } + recordDisconnect() { + this.disconnectedAt = now(); + logger.log("ConnectionMonitor recorded disconnect"); + } + startPolling() { + this.stopPolling(); + this.poll(); + } + stopPolling() { + clearTimeout(this.pollTimeout); + } + poll() { + this.pollTimeout = setTimeout((() => { + this.reconnectIfStale(); + this.poll(); + }), this.getPollInterval()); + } + getPollInterval() { + const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor; + const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)); + const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate; + const jitter = jitterMax * Math.random(); + return staleThreshold * 1e3 * backoff * (1 + jitter); + } + reconnectIfStale() { + if (this.connectionIsStale()) { + logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`); + this.reconnectAttempts++; + if (this.disconnectedRecently()) { + logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`); + } else { + logger.log("ConnectionMonitor reopening"); + this.connection.reopen(); + } + } + } + get refreshedAt() { + return this.pingedAt ? this.pingedAt : this.startedAt; + } + connectionIsStale() { + return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; + } + disconnectedRecently() { + return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold; + } + visibilityDidChange() { + if (document.visibilityState === "visible") { + setTimeout((() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`); + this.connection.reopen(); + } + }), 200); + } + } +} + +ConnectionMonitor.staleThreshold = 6; + +ConnectionMonitor.reconnectionBackoffRate = .15; + +var INTERNAL = { + message_types: { + welcome: "welcome", + disconnect: "disconnect", + ping: "ping", + confirmation: "confirm_subscription", + rejection: "reject_subscription" + }, + disconnect_reasons: { + unauthorized: "unauthorized", + invalid_request: "invalid_request", + server_restart: "server_restart", + remote: "remote" + }, + default_mount_path: "/cable", + protocols: [ "actioncable-v1-json", "actioncable-unsupported" ] +}; + +const {message_types: message_types, protocols: protocols} = INTERNAL; + +const supportedProtocols = protocols.slice(0, protocols.length - 1); + +const indexOf = [].indexOf; + +class Connection { + constructor(consumer) { + this.open = this.open.bind(this); + this.consumer = consumer; + this.subscriptions = this.consumer.subscriptions; + this.monitor = new ConnectionMonitor(this); + this.disconnected = true; + } + send(data) { + if (this.isOpen()) { + this.webSocket.send(JSON.stringify(data)); + return true; + } else { + return false; + } + } + open() { + if (this.isActive()) { + logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`); + return false; + } else { + const socketProtocols = [ ...protocols, ...this.consumer.subprotocols || [] ]; + logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${socketProtocols}`); + if (this.webSocket) { + this.uninstallEventHandlers(); + } + this.webSocket = new adapters.WebSocket(this.consumer.url, socketProtocols); + this.installEventHandlers(); + this.monitor.start(); + return true; + } + } + close({allowReconnect: allowReconnect} = { + allowReconnect: true + }) { + if (!allowReconnect) { + this.monitor.stop(); + } + if (this.isOpen()) { + return this.webSocket.close(); + } + } + reopen() { + logger.log(`Reopening WebSocket, current state is ${this.getState()}`); + if (this.isActive()) { + try { + return this.close(); + } catch (error) { + logger.log("Failed to reopen WebSocket", error); + } finally { + logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`); + setTimeout(this.open, this.constructor.reopenDelay); + } + } else { + return this.open(); + } + } + getProtocol() { + if (this.webSocket) { + return this.webSocket.protocol; + } + } + isOpen() { + return this.isState("open"); + } + isActive() { + return this.isState("open", "connecting"); + } + triedToReconnect() { + return this.monitor.reconnectAttempts > 0; + } + isProtocolSupported() { + return indexOf.call(supportedProtocols, this.getProtocol()) >= 0; + } + isState(...states) { + return indexOf.call(states, this.getState()) >= 0; + } + getState() { + if (this.webSocket) { + for (let state in adapters.WebSocket) { + if (adapters.WebSocket[state] === this.webSocket.readyState) { + return state.toLowerCase(); + } + } + } + return null; + } + installEventHandlers() { + for (let eventName in this.events) { + const handler = this.events[eventName].bind(this); + this.webSocket[`on${eventName}`] = handler; + } + } + uninstallEventHandlers() { + for (let eventName in this.events) { + this.webSocket[`on${eventName}`] = function() {}; + } + } +} + +Connection.reopenDelay = 500; + +Connection.prototype.events = { + message(event) { + if (!this.isProtocolSupported()) { + return; + } + const {identifier: identifier, message: message, reason: reason, reconnect: reconnect, type: type} = JSON.parse(event.data); + this.monitor.recordMessage(); + switch (type) { + case message_types.welcome: + if (this.triedToReconnect()) { + this.reconnectAttempted = true; + } + this.monitor.recordConnect(); + return this.subscriptions.reload(); + + case message_types.disconnect: + logger.log(`Disconnecting. Reason: ${reason}`); + return this.close({ + allowReconnect: reconnect + }); + + case message_types.ping: + return null; + + case message_types.confirmation: + this.subscriptions.confirmSubscription(identifier); + if (this.reconnectAttempted) { + this.reconnectAttempted = false; + return this.subscriptions.notify(identifier, "connected", { + reconnected: true + }); + } else { + return this.subscriptions.notify(identifier, "connected", { + reconnected: false + }); + } + + case message_types.rejection: + return this.subscriptions.reject(identifier); + + default: + return this.subscriptions.notify(identifier, "received", message); + } + }, + open() { + logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`); + this.disconnected = false; + if (!this.isProtocolSupported()) { + logger.log("Protocol is unsupported. Stopping monitor and disconnecting."); + return this.close({ + allowReconnect: false + }); + } + }, + close(event) { + logger.log("WebSocket onclose event"); + if (this.disconnected) { + return; + } + this.disconnected = true; + this.monitor.recordDisconnect(); + return this.subscriptions.notifyAll("disconnected", { + willAttemptReconnect: this.monitor.isRunning() + }); + }, + error() { + logger.log("WebSocket onerror event"); + } +}; + +const extend = function(object, properties) { + if (properties != null) { + for (let key in properties) { + const value = properties[key]; + object[key] = value; + } + } + return object; +}; + +class Subscription { + constructor(consumer, params = {}, mixin) { + this.consumer = consumer; + this.identifier = JSON.stringify(params); + extend(this, mixin); + } + perform(action, data = {}) { + data.action = action; + return this.send(data); + } + send(data) { + return this.consumer.send({ + command: "message", + identifier: this.identifier, + data: JSON.stringify(data) + }); + } + unsubscribe() { + return this.consumer.subscriptions.remove(this); + } +} + +class SubscriptionGuarantor { + constructor(subscriptions) { + this.subscriptions = subscriptions; + this.pendingSubscriptions = []; + } + guarantee(subscription) { + if (this.pendingSubscriptions.indexOf(subscription) == -1) { + logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`); + this.pendingSubscriptions.push(subscription); + } else { + logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`); + } + this.startGuaranteeing(); + } + forget(subscription) { + logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`); + this.pendingSubscriptions = this.pendingSubscriptions.filter((s => s !== subscription)); + } + startGuaranteeing() { + this.stopGuaranteeing(); + this.retrySubscribing(); + } + stopGuaranteeing() { + clearTimeout(this.retryTimeout); + } + retrySubscribing() { + this.retryTimeout = setTimeout((() => { + if (this.subscriptions && typeof this.subscriptions.subscribe === "function") { + this.pendingSubscriptions.map((subscription => { + logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`); + this.subscriptions.subscribe(subscription); + })); + } + }), 500); + } +} + +class Subscriptions { + constructor(consumer) { + this.consumer = consumer; + this.guarantor = new SubscriptionGuarantor(this); + this.subscriptions = []; + } + create(channelName, mixin) { + const channel = channelName; + const params = typeof channel === "object" ? channel : { + channel: channel + }; + const subscription = new Subscription(this.consumer, params, mixin); + return this.add(subscription); + } + add(subscription) { + this.subscriptions.push(subscription); + this.consumer.ensureActiveConnection(); + this.notify(subscription, "initialized"); + this.subscribe(subscription); + return subscription; + } + remove(subscription) { + this.forget(subscription); + if (!this.findAll(subscription.identifier).length) { + this.sendCommand(subscription, "unsubscribe"); + } + return subscription; + } + reject(identifier) { + return this.findAll(identifier).map((subscription => { + this.forget(subscription); + this.notify(subscription, "rejected"); + return subscription; + })); + } + forget(subscription) { + this.guarantor.forget(subscription); + this.subscriptions = this.subscriptions.filter((s => s !== subscription)); + return subscription; + } + findAll(identifier) { + return this.subscriptions.filter((s => s.identifier === identifier)); + } + reload() { + return this.subscriptions.map((subscription => this.subscribe(subscription))); + } + notifyAll(callbackName, ...args) { + return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args))); + } + notify(subscription, callbackName, ...args) { + let subscriptions; + if (typeof subscription === "string") { + subscriptions = this.findAll(subscription); + } else { + subscriptions = [ subscription ]; + } + return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined)); + } + subscribe(subscription) { + if (this.sendCommand(subscription, "subscribe")) { + this.guarantor.guarantee(subscription); + } + } + confirmSubscription(identifier) { + logger.log(`Subscription confirmed ${identifier}`); + this.findAll(identifier).map((subscription => this.guarantor.forget(subscription))); + } + sendCommand(subscription, command) { + const {identifier: identifier} = subscription; + return this.consumer.send({ + command: command, + identifier: identifier + }); + } +} + +class Consumer { + constructor(url) { + this._url = url; + this.subscriptions = new Subscriptions(this); + this.connection = new Connection(this); + this.subprotocols = []; + } + get url() { + return createWebSocketURL(this._url); + } + send(data) { + return this.connection.send(data); + } + connect() { + return this.connection.open(); + } + disconnect() { + return this.connection.close({ + allowReconnect: false + }); + } + ensureActiveConnection() { + if (!this.connection.isActive()) { + return this.connection.open(); + } + } + addSubProtocol(subprotocol) { + this.subprotocols = [ ...this.subprotocols, subprotocol ]; + } +} + +function createWebSocketURL(url) { + if (typeof url === "function") { + url = url(); + } + if (url && !/^wss?:/i.test(url)) { + const a = document.createElement("a"); + a.href = url; + a.href = a.href; + a.protocol = a.protocol.replace("http", "ws"); + return a.href; + } else { + return url; + } +} + +function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) { + return new Consumer(url); +} + +function getConfig(name) { + const element = document.head.querySelector(`meta[name='action-cable-${name}']`); + if (element) { + return element.getAttribute("content"); + } +} + +export { Connection, ConnectionMonitor, Consumer, INTERNAL, Subscription, SubscriptionGuarantor, Subscriptions, adapters, createConsumer, createWebSocketURL, getConfig, logger }; diff --git a/actioncable/app/assets/javascripts/actioncable.js b/actioncable/app/assets/javascripts/actioncable.js new file mode 100644 index 0000000000000..5fc994339fb36 --- /dev/null +++ b/actioncable/app/assets/javascripts/actioncable.js @@ -0,0 +1,510 @@ +(function(global, factory) { + typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define([ "exports" ], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, + factory(global.ActionCable = {})); +})(this, (function(exports) { + "use strict"; + var adapters = { + logger: typeof console !== "undefined" ? console : undefined, + WebSocket: typeof WebSocket !== "undefined" ? WebSocket : undefined + }; + var logger = { + log(...messages) { + if (this.enabled) { + messages.push(Date.now()); + adapters.logger.log("[ActionCable]", ...messages); + } + } + }; + const now = () => (new Date).getTime(); + const secondsSince = time => (now() - time) / 1e3; + class ConnectionMonitor { + constructor(connection) { + this.visibilityDidChange = this.visibilityDidChange.bind(this); + this.connection = connection; + this.reconnectAttempts = 0; + } + start() { + if (!this.isRunning()) { + this.startedAt = now(); + delete this.stoppedAt; + this.startPolling(); + addEventListener("visibilitychange", this.visibilityDidChange); + logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`); + } + } + stop() { + if (this.isRunning()) { + this.stoppedAt = now(); + this.stopPolling(); + removeEventListener("visibilitychange", this.visibilityDidChange); + logger.log("ConnectionMonitor stopped"); + } + } + isRunning() { + return this.startedAt && !this.stoppedAt; + } + recordMessage() { + this.pingedAt = now(); + } + recordConnect() { + this.reconnectAttempts = 0; + delete this.disconnectedAt; + logger.log("ConnectionMonitor recorded connect"); + } + recordDisconnect() { + this.disconnectedAt = now(); + logger.log("ConnectionMonitor recorded disconnect"); + } + startPolling() { + this.stopPolling(); + this.poll(); + } + stopPolling() { + clearTimeout(this.pollTimeout); + } + poll() { + this.pollTimeout = setTimeout((() => { + this.reconnectIfStale(); + this.poll(); + }), this.getPollInterval()); + } + getPollInterval() { + const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor; + const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)); + const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate; + const jitter = jitterMax * Math.random(); + return staleThreshold * 1e3 * backoff * (1 + jitter); + } + reconnectIfStale() { + if (this.connectionIsStale()) { + logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`); + this.reconnectAttempts++; + if (this.disconnectedRecently()) { + logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`); + } else { + logger.log("ConnectionMonitor reopening"); + this.connection.reopen(); + } + } + } + get refreshedAt() { + return this.pingedAt ? this.pingedAt : this.startedAt; + } + connectionIsStale() { + return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; + } + disconnectedRecently() { + return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold; + } + visibilityDidChange() { + if (document.visibilityState === "visible") { + setTimeout((() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`); + this.connection.reopen(); + } + }), 200); + } + } + } + ConnectionMonitor.staleThreshold = 6; + ConnectionMonitor.reconnectionBackoffRate = .15; + var INTERNAL = { + message_types: { + welcome: "welcome", + disconnect: "disconnect", + ping: "ping", + confirmation: "confirm_subscription", + rejection: "reject_subscription" + }, + disconnect_reasons: { + unauthorized: "unauthorized", + invalid_request: "invalid_request", + server_restart: "server_restart", + remote: "remote" + }, + default_mount_path: "/cable", + protocols: [ "actioncable-v1-json", "actioncable-unsupported" ] + }; + const {message_types: message_types, protocols: protocols} = INTERNAL; + const supportedProtocols = protocols.slice(0, protocols.length - 1); + const indexOf = [].indexOf; + class Connection { + constructor(consumer) { + this.open = this.open.bind(this); + this.consumer = consumer; + this.subscriptions = this.consumer.subscriptions; + this.monitor = new ConnectionMonitor(this); + this.disconnected = true; + } + send(data) { + if (this.isOpen()) { + this.webSocket.send(JSON.stringify(data)); + return true; + } else { + return false; + } + } + open() { + if (this.isActive()) { + logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`); + return false; + } else { + const socketProtocols = [ ...protocols, ...this.consumer.subprotocols || [] ]; + logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${socketProtocols}`); + if (this.webSocket) { + this.uninstallEventHandlers(); + } + this.webSocket = new adapters.WebSocket(this.consumer.url, socketProtocols); + this.installEventHandlers(); + this.monitor.start(); + return true; + } + } + close({allowReconnect: allowReconnect} = { + allowReconnect: true + }) { + if (!allowReconnect) { + this.monitor.stop(); + } + if (this.isOpen()) { + return this.webSocket.close(); + } + } + reopen() { + logger.log(`Reopening WebSocket, current state is ${this.getState()}`); + if (this.isActive()) { + try { + return this.close(); + } catch (error) { + logger.log("Failed to reopen WebSocket", error); + } finally { + logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`); + setTimeout(this.open, this.constructor.reopenDelay); + } + } else { + return this.open(); + } + } + getProtocol() { + if (this.webSocket) { + return this.webSocket.protocol; + } + } + isOpen() { + return this.isState("open"); + } + isActive() { + return this.isState("open", "connecting"); + } + triedToReconnect() { + return this.monitor.reconnectAttempts > 0; + } + isProtocolSupported() { + return indexOf.call(supportedProtocols, this.getProtocol()) >= 0; + } + isState(...states) { + return indexOf.call(states, this.getState()) >= 0; + } + getState() { + if (this.webSocket) { + for (let state in adapters.WebSocket) { + if (adapters.WebSocket[state] === this.webSocket.readyState) { + return state.toLowerCase(); + } + } + } + return null; + } + installEventHandlers() { + for (let eventName in this.events) { + const handler = this.events[eventName].bind(this); + this.webSocket[`on${eventName}`] = handler; + } + } + uninstallEventHandlers() { + for (let eventName in this.events) { + this.webSocket[`on${eventName}`] = function() {}; + } + } + } + Connection.reopenDelay = 500; + Connection.prototype.events = { + message(event) { + if (!this.isProtocolSupported()) { + return; + } + const {identifier: identifier, message: message, reason: reason, reconnect: reconnect, type: type} = JSON.parse(event.data); + this.monitor.recordMessage(); + switch (type) { + case message_types.welcome: + if (this.triedToReconnect()) { + this.reconnectAttempted = true; + } + this.monitor.recordConnect(); + return this.subscriptions.reload(); + + case message_types.disconnect: + logger.log(`Disconnecting. Reason: ${reason}`); + return this.close({ + allowReconnect: reconnect + }); + + case message_types.ping: + return null; + + case message_types.confirmation: + this.subscriptions.confirmSubscription(identifier); + if (this.reconnectAttempted) { + this.reconnectAttempted = false; + return this.subscriptions.notify(identifier, "connected", { + reconnected: true + }); + } else { + return this.subscriptions.notify(identifier, "connected", { + reconnected: false + }); + } + + case message_types.rejection: + return this.subscriptions.reject(identifier); + + default: + return this.subscriptions.notify(identifier, "received", message); + } + }, + open() { + logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`); + this.disconnected = false; + if (!this.isProtocolSupported()) { + logger.log("Protocol is unsupported. Stopping monitor and disconnecting."); + return this.close({ + allowReconnect: false + }); + } + }, + close(event) { + logger.log("WebSocket onclose event"); + if (this.disconnected) { + return; + } + this.disconnected = true; + this.monitor.recordDisconnect(); + return this.subscriptions.notifyAll("disconnected", { + willAttemptReconnect: this.monitor.isRunning() + }); + }, + error() { + logger.log("WebSocket onerror event"); + } + }; + const extend = function(object, properties) { + if (properties != null) { + for (let key in properties) { + const value = properties[key]; + object[key] = value; + } + } + return object; + }; + class Subscription { + constructor(consumer, params = {}, mixin) { + this.consumer = consumer; + this.identifier = JSON.stringify(params); + extend(this, mixin); + } + perform(action, data = {}) { + data.action = action; + return this.send(data); + } + send(data) { + return this.consumer.send({ + command: "message", + identifier: this.identifier, + data: JSON.stringify(data) + }); + } + unsubscribe() { + return this.consumer.subscriptions.remove(this); + } + } + class SubscriptionGuarantor { + constructor(subscriptions) { + this.subscriptions = subscriptions; + this.pendingSubscriptions = []; + } + guarantee(subscription) { + if (this.pendingSubscriptions.indexOf(subscription) == -1) { + logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`); + this.pendingSubscriptions.push(subscription); + } else { + logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`); + } + this.startGuaranteeing(); + } + forget(subscription) { + logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`); + this.pendingSubscriptions = this.pendingSubscriptions.filter((s => s !== subscription)); + } + startGuaranteeing() { + this.stopGuaranteeing(); + this.retrySubscribing(); + } + stopGuaranteeing() { + clearTimeout(this.retryTimeout); + } + retrySubscribing() { + this.retryTimeout = setTimeout((() => { + if (this.subscriptions && typeof this.subscriptions.subscribe === "function") { + this.pendingSubscriptions.map((subscription => { + logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`); + this.subscriptions.subscribe(subscription); + })); + } + }), 500); + } + } + class Subscriptions { + constructor(consumer) { + this.consumer = consumer; + this.guarantor = new SubscriptionGuarantor(this); + this.subscriptions = []; + } + create(channelName, mixin) { + const channel = channelName; + const params = typeof channel === "object" ? channel : { + channel: channel + }; + const subscription = new Subscription(this.consumer, params, mixin); + return this.add(subscription); + } + add(subscription) { + this.subscriptions.push(subscription); + this.consumer.ensureActiveConnection(); + this.notify(subscription, "initialized"); + this.subscribe(subscription); + return subscription; + } + remove(subscription) { + this.forget(subscription); + if (!this.findAll(subscription.identifier).length) { + this.sendCommand(subscription, "unsubscribe"); + } + return subscription; + } + reject(identifier) { + return this.findAll(identifier).map((subscription => { + this.forget(subscription); + this.notify(subscription, "rejected"); + return subscription; + })); + } + forget(subscription) { + this.guarantor.forget(subscription); + this.subscriptions = this.subscriptions.filter((s => s !== subscription)); + return subscription; + } + findAll(identifier) { + return this.subscriptions.filter((s => s.identifier === identifier)); + } + reload() { + return this.subscriptions.map((subscription => this.subscribe(subscription))); + } + notifyAll(callbackName, ...args) { + return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args))); + } + notify(subscription, callbackName, ...args) { + let subscriptions; + if (typeof subscription === "string") { + subscriptions = this.findAll(subscription); + } else { + subscriptions = [ subscription ]; + } + return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined)); + } + subscribe(subscription) { + if (this.sendCommand(subscription, "subscribe")) { + this.guarantor.guarantee(subscription); + } + } + confirmSubscription(identifier) { + logger.log(`Subscription confirmed ${identifier}`); + this.findAll(identifier).map((subscription => this.guarantor.forget(subscription))); + } + sendCommand(subscription, command) { + const {identifier: identifier} = subscription; + return this.consumer.send({ + command: command, + identifier: identifier + }); + } + } + class Consumer { + constructor(url) { + this._url = url; + this.subscriptions = new Subscriptions(this); + this.connection = new Connection(this); + this.subprotocols = []; + } + get url() { + return createWebSocketURL(this._url); + } + send(data) { + return this.connection.send(data); + } + connect() { + return this.connection.open(); + } + disconnect() { + return this.connection.close({ + allowReconnect: false + }); + } + ensureActiveConnection() { + if (!this.connection.isActive()) { + return this.connection.open(); + } + } + addSubProtocol(subprotocol) { + this.subprotocols = [ ...this.subprotocols, subprotocol ]; + } + } + function createWebSocketURL(url) { + if (typeof url === "function") { + url = url(); + } + if (url && !/^wss?:/i.test(url)) { + const a = document.createElement("a"); + a.href = url; + a.href = a.href; + a.protocol = a.protocol.replace("http", "ws"); + return a.href; + } else { + return url; + } + } + function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) { + return new Consumer(url); + } + function getConfig(name) { + const element = document.head.querySelector(`meta[name='action-cable-${name}']`); + if (element) { + return element.getAttribute("content"); + } + } + exports.Connection = Connection; + exports.ConnectionMonitor = ConnectionMonitor; + exports.Consumer = Consumer; + exports.INTERNAL = INTERNAL; + exports.Subscription = Subscription; + exports.SubscriptionGuarantor = SubscriptionGuarantor; + exports.Subscriptions = Subscriptions; + exports.adapters = adapters; + exports.createConsumer = createConsumer; + exports.createWebSocketURL = createWebSocketURL; + exports.getConfig = getConfig; + exports.logger = logger; + Object.defineProperty(exports, "__esModule", { + value: true + }); +})); diff --git a/actioncable/app/javascript/action_cable/adapters.js b/actioncable/app/javascript/action_cable/adapters.js new file mode 100644 index 0000000000000..f9759de9a05dd --- /dev/null +++ b/actioncable/app/javascript/action_cable/adapters.js @@ -0,0 +1,4 @@ +export default { + logger: typeof console !== "undefined" ? console : undefined, + WebSocket: typeof WebSocket !== "undefined" ? WebSocket : undefined, +} diff --git a/actioncable/app/javascript/action_cable/connection.js b/actioncable/app/javascript/action_cable/connection.js new file mode 100644 index 0000000000000..fa32cfd5a675f --- /dev/null +++ b/actioncable/app/javascript/action_cable/connection.js @@ -0,0 +1,181 @@ +import adapters from "./adapters" +import ConnectionMonitor from "./connection_monitor" +import INTERNAL from "./internal" +import logger from "./logger" + +// Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. + +const {message_types, protocols} = INTERNAL +const supportedProtocols = protocols.slice(0, protocols.length - 1) + +const indexOf = [].indexOf + +class Connection { + constructor(consumer) { + this.open = this.open.bind(this) + this.consumer = consumer + this.subscriptions = this.consumer.subscriptions + this.monitor = new ConnectionMonitor(this) + this.disconnected = true + } + + send(data) { + if (this.isOpen()) { + this.webSocket.send(JSON.stringify(data)) + return true + } else { + return false + } + } + + open() { + if (this.isActive()) { + logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`) + return false + } else { + const socketProtocols = [...protocols, ...this.consumer.subprotocols || []] + logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${socketProtocols}`) + if (this.webSocket) { this.uninstallEventHandlers() } + this.webSocket = new adapters.WebSocket(this.consumer.url, socketProtocols) + this.installEventHandlers() + this.monitor.start() + return true + } + } + + close({allowReconnect} = {allowReconnect: true}) { + if (!allowReconnect) { this.monitor.stop() } + // Avoid closing websockets in a "connecting" state due to Safari 15.1+ bug. See: https://github.com/rails/rails/issues/43835#issuecomment-1002288478 + if (this.isOpen()) { + return this.webSocket.close() + } + } + + reopen() { + logger.log(`Reopening WebSocket, current state is ${this.getState()}`) + if (this.isActive()) { + try { + return this.close() + } catch (error) { + logger.log("Failed to reopen WebSocket", error) + } + finally { + logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`) + setTimeout(this.open, this.constructor.reopenDelay) + } + } else { + return this.open() + } + } + + getProtocol() { + if (this.webSocket) { + return this.webSocket.protocol + } + } + + isOpen() { + return this.isState("open") + } + + isActive() { + return this.isState("open", "connecting") + } + + triedToReconnect() { + return this.monitor.reconnectAttempts > 0 + } + + // Private + + isProtocolSupported() { + return indexOf.call(supportedProtocols, this.getProtocol()) >= 0 + } + + isState(...states) { + return indexOf.call(states, this.getState()) >= 0 + } + + getState() { + if (this.webSocket) { + for (let state in adapters.WebSocket) { + if (adapters.WebSocket[state] === this.webSocket.readyState) { + return state.toLowerCase() + } + } + } + return null + } + + installEventHandlers() { + for (let eventName in this.events) { + const handler = this.events[eventName].bind(this) + this.webSocket[`on${eventName}`] = handler + } + } + + uninstallEventHandlers() { + for (let eventName in this.events) { + this.webSocket[`on${eventName}`] = function() {} + } + } + +} + +Connection.reopenDelay = 500 + +Connection.prototype.events = { + message(event) { + if (!this.isProtocolSupported()) { return } + const {identifier, message, reason, reconnect, type} = JSON.parse(event.data) + this.monitor.recordMessage() + switch (type) { + case message_types.welcome: + if (this.triedToReconnect()) { + this.reconnectAttempted = true + } + this.monitor.recordConnect() + return this.subscriptions.reload() + case message_types.disconnect: + logger.log(`Disconnecting. Reason: ${reason}`) + return this.close({allowReconnect: reconnect}) + case message_types.ping: + return null + case message_types.confirmation: + this.subscriptions.confirmSubscription(identifier) + if (this.reconnectAttempted) { + this.reconnectAttempted = false + return this.subscriptions.notify(identifier, "connected", {reconnected: true}) + } else { + return this.subscriptions.notify(identifier, "connected", {reconnected: false}) + } + case message_types.rejection: + return this.subscriptions.reject(identifier) + default: + return this.subscriptions.notify(identifier, "received", message) + } + }, + + open() { + logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`) + this.disconnected = false + if (!this.isProtocolSupported()) { + logger.log("Protocol is unsupported. Stopping monitor and disconnecting.") + return this.close({allowReconnect: false}) + } + }, + + close(event) { + logger.log("WebSocket onclose event") + if (this.disconnected) { return } + this.disconnected = true + this.monitor.recordDisconnect() + return this.subscriptions.notifyAll("disconnected", {willAttemptReconnect: this.monitor.isRunning()}) + }, + + error() { + logger.log("WebSocket onerror event") + } +} + +export default Connection diff --git a/actioncable/app/javascript/action_cable/connection_monitor.js b/actioncable/app/javascript/action_cable/connection_monitor.js new file mode 100644 index 0000000000000..986d1408e0ba2 --- /dev/null +++ b/actioncable/app/javascript/action_cable/connection_monitor.js @@ -0,0 +1,124 @@ +import logger from "./logger" + +// Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting +// revival reconnections if things go astray. Internal class, not intended for direct user manipulation. + +const now = () => new Date().getTime() + +const secondsSince = time => (now() - time) / 1000 + +class ConnectionMonitor { + constructor(connection) { + this.visibilityDidChange = this.visibilityDidChange.bind(this) + this.connection = connection + this.reconnectAttempts = 0 + } + + start() { + if (!this.isRunning()) { + this.startedAt = now() + delete this.stoppedAt + this.startPolling() + addEventListener("visibilitychange", this.visibilityDidChange) + logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`) + } + } + + stop() { + if (this.isRunning()) { + this.stoppedAt = now() + this.stopPolling() + removeEventListener("visibilitychange", this.visibilityDidChange) + logger.log("ConnectionMonitor stopped") + } + } + + isRunning() { + return this.startedAt && !this.stoppedAt + } + + recordMessage() { + this.pingedAt = now() + } + + recordConnect() { + this.reconnectAttempts = 0 + delete this.disconnectedAt + logger.log("ConnectionMonitor recorded connect") + } + + recordDisconnect() { + this.disconnectedAt = now() + logger.log("ConnectionMonitor recorded disconnect") + } + + // Private + + startPolling() { + this.stopPolling() + this.poll() + } + + stopPolling() { + clearTimeout(this.pollTimeout) + } + + poll() { + this.pollTimeout = setTimeout(() => { + this.reconnectIfStale() + this.poll() + } + , this.getPollInterval()) + } + + getPollInterval() { + const { staleThreshold, reconnectionBackoffRate } = this.constructor + const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)) + const jitterMax = this.reconnectAttempts === 0 ? 1.0 : reconnectionBackoffRate + const jitter = jitterMax * Math.random() + return staleThreshold * 1000 * backoff * (1 + jitter) + } + + reconnectIfStale() { + if (this.connectionIsStale()) { + logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`) + this.reconnectAttempts++ + if (this.disconnectedRecently()) { + logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`) + } else { + logger.log("ConnectionMonitor reopening") + this.connection.reopen() + } + } + } + + get refreshedAt() { + return this.pingedAt ? this.pingedAt : this.startedAt + } + + connectionIsStale() { + return secondsSince(this.refreshedAt) > this.constructor.staleThreshold + } + + disconnectedRecently() { + return this.disconnectedAt && (secondsSince(this.disconnectedAt) < this.constructor.staleThreshold) + } + + visibilityDidChange() { + if (document.visibilityState === "visible") { + setTimeout(() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`) + this.connection.reopen() + } + } + , 200) + } + } + +} + +ConnectionMonitor.staleThreshold = 6 // Server::Connections::BEAT_INTERVAL * 2 (missed two pings) +ConnectionMonitor.reconnectionBackoffRate = 0.15 + +export default ConnectionMonitor diff --git a/actioncable/app/javascript/action_cable/consumer.js b/actioncable/app/javascript/action_cable/consumer.js new file mode 100644 index 0000000000000..71ef125a45537 --- /dev/null +++ b/actioncable/app/javascript/action_cable/consumer.js @@ -0,0 +1,80 @@ +import Connection from "./connection" +import Subscriptions from "./subscriptions" + +// The ActionCable.Consumer establishes the connection to a server-side Ruby Connection object. Once established, +// the ActionCable.ConnectionMonitor will ensure that its properly maintained through heartbeats and checking for stale updates. +// The Consumer instance is also the gateway to establishing subscriptions to desired channels through the #createSubscription +// method. +// +// The following example shows how this can be set up: +// +// App = {} +// App.cable = ActionCable.createConsumer("ws://example.com/accounts/1") +// App.appearance = App.cable.subscriptions.create("AppearanceChannel") +// +// For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription. +// +// When a consumer is created, it automatically connects with the server. +// +// To disconnect from the server, call +// +// App.cable.disconnect() +// +// and to restart the connection: +// +// App.cable.connect() +// +// Any channel subscriptions which existed prior to disconnecting will +// automatically resubscribe. + +export default class Consumer { + constructor(url) { + this._url = url + this.subscriptions = new Subscriptions(this) + this.connection = new Connection(this) + this.subprotocols = [] + } + + get url() { + return createWebSocketURL(this._url) + } + + send(data) { + return this.connection.send(data) + } + + connect() { + return this.connection.open() + } + + disconnect() { + return this.connection.close({allowReconnect: false}) + } + + ensureActiveConnection() { + if (!this.connection.isActive()) { + return this.connection.open() + } + } + + addSubProtocol(subprotocol) { + this.subprotocols = [...this.subprotocols, subprotocol] + } +} + +export function createWebSocketURL(url) { + if (typeof url === "function") { + url = url() + } + + if (url && !/^wss?:/i.test(url)) { + const a = document.createElement("a") + a.href = url + // Fix populating Location properties in IE. Otherwise, protocol will be blank. + a.href = a.href // eslint-disable-line + a.protocol = a.protocol.replace("http", "ws") + return a.href + } else { + return url + } +} diff --git a/actioncable/app/javascript/action_cable/index.js b/actioncable/app/javascript/action_cable/index.js new file mode 100644 index 0000000000000..3e650bc120bc0 --- /dev/null +++ b/actioncable/app/javascript/action_cable/index.js @@ -0,0 +1,33 @@ +import Connection from "./connection" +import ConnectionMonitor from "./connection_monitor" +import Consumer, { createWebSocketURL } from "./consumer" +import INTERNAL from "./internal" +import Subscription from "./subscription" +import Subscriptions from "./subscriptions" +import SubscriptionGuarantor from "./subscription_guarantor" +import adapters from "./adapters" +import logger from "./logger" + +export { + Connection, + ConnectionMonitor, + Consumer, + INTERNAL, + Subscription, + Subscriptions, + SubscriptionGuarantor, + adapters, + createWebSocketURL, + logger, +} + +export function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) { + return new Consumer(url) +} + +export function getConfig(name) { + const element = document.head.querySelector(`meta[name='action-cable-${name}']`) + if (element) { + return element.getAttribute("content") + } +} diff --git a/actioncable/app/javascript/action_cable/index_with_name_deprecation.js b/actioncable/app/javascript/action_cable/index_with_name_deprecation.js new file mode 100644 index 0000000000000..272ba7bec2221 --- /dev/null +++ b/actioncable/app/javascript/action_cable/index_with_name_deprecation.js @@ -0,0 +1,2 @@ +export * from "./index" +console.log("DEPRECATION: action_cable.js has been renamed to actioncable.js – please update your reference before Rails 8") diff --git a/actioncable/app/javascript/action_cable/internal.js b/actioncable/app/javascript/action_cable/internal.js new file mode 100644 index 0000000000000..a007d6f471f7a --- /dev/null +++ b/actioncable/app/javascript/action_cable/internal.js @@ -0,0 +1,20 @@ +export default { + "message_types": { + "welcome": "welcome", + "disconnect": "disconnect", + "ping": "ping", + "confirmation": "confirm_subscription", + "rejection": "reject_subscription" + }, + "disconnect_reasons": { + "unauthorized": "unauthorized", + "invalid_request": "invalid_request", + "server_restart": "server_restart", + "remote": "remote" + }, + "default_mount_path": "/cable", + "protocols": [ + "actioncable-v1-json", + "actioncable-unsupported" + ] +} diff --git a/actioncable/app/javascript/action_cable/logger.js b/actioncable/app/javascript/action_cable/logger.js new file mode 100644 index 0000000000000..c73f5bd542fc5 --- /dev/null +++ b/actioncable/app/javascript/action_cable/logger.js @@ -0,0 +1,22 @@ +import adapters from "./adapters" + +// The logger is disabled by default. You can enable it with: +// +// ActionCable.logger.enabled = true +// +// Example: +// +// import * as ActionCable from '@rails/actioncable' +// +// ActionCable.logger.enabled = true +// ActionCable.logger.log('Connection Established.') +// + +export default { + log(...messages) { + if (this.enabled) { + messages.push(Date.now()) + adapters.logger.log("[ActionCable]", ...messages) + } + }, +} diff --git a/actioncable/app/javascript/action_cable/subscription.js b/actioncable/app/javascript/action_cable/subscription.js new file mode 100644 index 0000000000000..7de08f93b3de5 --- /dev/null +++ b/actioncable/app/javascript/action_cable/subscription.js @@ -0,0 +1,89 @@ +// A new subscription is created through the ActionCable.Subscriptions instance available on the consumer. +// It provides a number of callbacks and a method for calling remote procedure calls on the corresponding +// Channel instance on the server side. +// +// An example demonstrates the basic functionality: +// +// App.appearance = App.cable.subscriptions.create("AppearanceChannel", { +// connected() { +// // Called once the subscription has been successfully completed +// }, +// +// disconnected({ willAttemptReconnect: boolean }) { +// // Called when the client has disconnected with the server. +// // The object will have an `willAttemptReconnect` property which +// // says whether the client has the intention of attempting +// // to reconnect. +// }, +// +// appear() { +// this.perform('appear', {appearing_on: this.appearingOn()}) +// }, +// +// away() { +// this.perform('away') +// }, +// +// appearingOn() { +// $('main').data('appearing-on') +// } +// }) +// +// The methods #appear and #away forward their intent to the remote AppearanceChannel instance on the server +// by calling the `perform` method with the first parameter being the action (which maps to AppearanceChannel#appear/away). +// The second parameter is a hash that'll get JSON encoded and made available on the server in the data parameter. +// +// This is how the server component would look: +// +// class AppearanceChannel < ApplicationActionCable::Channel +// def subscribed +// current_user.appear +// end +// +// def unsubscribed +// current_user.disappear +// end +// +// def appear(data) +// current_user.appear on: data['appearing_on'] +// end +// +// def away +// current_user.away +// end +// end +// +// The "AppearanceChannel" name is automatically mapped between the client-side subscription creation and the server-side Ruby class name. +// The AppearanceChannel#appear/away public methods are exposed automatically to client-side invocation through the perform method. + +const extend = function(object, properties) { + if (properties != null) { + for (let key in properties) { + const value = properties[key] + object[key] = value + } + } + return object +} + +export default class Subscription { + constructor(consumer, params = {}, mixin) { + this.consumer = consumer + this.identifier = JSON.stringify(params) + extend(this, mixin) + } + + // Perform a channel action with the optional data passed as an attribute + perform(action, data = {}) { + data.action = action + return this.send(data) + } + + send(data) { + return this.consumer.send({command: "message", identifier: this.identifier, data: JSON.stringify(data)}) + } + + unsubscribe() { + return this.consumer.subscriptions.remove(this) + } +} diff --git a/actioncable/app/javascript/action_cable/subscription_guarantor.js b/actioncable/app/javascript/action_cable/subscription_guarantor.js new file mode 100644 index 0000000000000..7d6ad98fa3904 --- /dev/null +++ b/actioncable/app/javascript/action_cable/subscription_guarantor.js @@ -0,0 +1,50 @@ +import logger from "./logger" + +// Responsible for ensuring channel subscribe command is confirmed, retrying until confirmation is received. +// Internal class, not intended for direct user manipulation. + +class SubscriptionGuarantor { + constructor(subscriptions) { + this.subscriptions = subscriptions + this.pendingSubscriptions = [] + } + + guarantee(subscription) { + if(this.pendingSubscriptions.indexOf(subscription) == -1){ + logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`) + this.pendingSubscriptions.push(subscription) + } + else { + logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`) + } + this.startGuaranteeing() + } + + forget(subscription) { + logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`) + this.pendingSubscriptions = (this.pendingSubscriptions.filter((s) => s !== subscription)) + } + + startGuaranteeing() { + this.stopGuaranteeing() + this.retrySubscribing() + } + + stopGuaranteeing() { + clearTimeout(this.retryTimeout) + } + + retrySubscribing() { + this.retryTimeout = setTimeout(() => { + if (this.subscriptions && typeof(this.subscriptions.subscribe) === "function") { + this.pendingSubscriptions.map((subscription) => { + logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`) + this.subscriptions.subscribe(subscription) + }) + } + } + , 500) + } +} + +export default SubscriptionGuarantor \ No newline at end of file diff --git a/actioncable/app/javascript/action_cable/subscriptions.js b/actioncable/app/javascript/action_cable/subscriptions.js new file mode 100644 index 0000000000000..ec41ccbf75ba6 --- /dev/null +++ b/actioncable/app/javascript/action_cable/subscriptions.js @@ -0,0 +1,103 @@ +import Subscription from "./subscription" +import SubscriptionGuarantor from "./subscription_guarantor" +import logger from "./logger" + +// Collection class for creating (and internally managing) channel subscriptions. +// The only method intended to be triggered by the user is ActionCable.Subscriptions#create, +// and it should be called through the consumer like so: +// +// App = {} +// App.cable = ActionCable.createConsumer("ws://example.com/accounts/1") +// App.appearance = App.cable.subscriptions.create("AppearanceChannel") +// +// For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription. + +export default class Subscriptions { + constructor(consumer) { + this.consumer = consumer + this.guarantor = new SubscriptionGuarantor(this) + this.subscriptions = [] + } + + create(channelName, mixin) { + const channel = channelName + const params = typeof channel === "object" ? channel : {channel} + const subscription = new Subscription(this.consumer, params, mixin) + return this.add(subscription) + } + + // Private + + add(subscription) { + this.subscriptions.push(subscription) + this.consumer.ensureActiveConnection() + this.notify(subscription, "initialized") + this.subscribe(subscription) + return subscription + } + + remove(subscription) { + this.forget(subscription) + if (!this.findAll(subscription.identifier).length) { + this.sendCommand(subscription, "unsubscribe") + } + return subscription + } + + reject(identifier) { + return this.findAll(identifier).map((subscription) => { + this.forget(subscription) + this.notify(subscription, "rejected") + return subscription + }) + } + + forget(subscription) { + this.guarantor.forget(subscription) + this.subscriptions = (this.subscriptions.filter((s) => s !== subscription)) + return subscription + } + + findAll(identifier) { + return this.subscriptions.filter((s) => s.identifier === identifier) + } + + reload() { + return this.subscriptions.map((subscription) => + this.subscribe(subscription)) + } + + notifyAll(callbackName, ...args) { + return this.subscriptions.map((subscription) => + this.notify(subscription, callbackName, ...args)) + } + + notify(subscription, callbackName, ...args) { + let subscriptions + if (typeof subscription === "string") { + subscriptions = this.findAll(subscription) + } else { + subscriptions = [subscription] + } + + return subscriptions.map((subscription) => + (typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined)) + } + + subscribe(subscription) { + if (this.sendCommand(subscription, "subscribe")) { + this.guarantor.guarantee(subscription) + } + } + + confirmSubscription(identifier) { + logger.log(`Subscription confirmed ${identifier}`) + this.findAll(identifier).map((subscription) => + this.guarantor.forget(subscription)) + } + + sendCommand(subscription, command) { + const {identifier} = subscription + return this.consumer.send({command, identifier}) + } +} diff --git a/actioncable/bin/test b/actioncable/bin/test new file mode 100755 index 0000000000000..c53377cc970f4 --- /dev/null +++ b/actioncable/bin/test @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +COMPONENT_ROOT = File.expand_path("..", __dir__) +require_relative "../../tools/test" diff --git a/actioncable/karma.conf.js b/actioncable/karma.conf.js new file mode 100644 index 0000000000000..bb16f969546d1 --- /dev/null +++ b/actioncable/karma.conf.js @@ -0,0 +1,63 @@ +const config = { + browsers: ["ChromeHeadless"], + frameworks: ["qunit"], + files: [ + "test/javascript/compiled/test.js", + ], + + client: { + clearContext: false, + qunit: { + showUI: true + } + }, + + singleRun: true, + autoWatch: false, + + captureTimeout: 180000, + browserDisconnectTimeout: 180000, + browserDisconnectTolerance: 3, + browserNoActivityTimeout: 300000, +} + +if (process.env.CI) { + config.customLaunchers = { + sl_chrome: sauce("chrome", 70), + sl_ff: sauce("firefox", 63), + sl_safari: sauce("safari", "16", "macOS 13"), + sl_edge: sauce("microsoftedge", 17.17134, "Windows 10"), + } + + config.browsers = Object.keys(config.customLaunchers) + config.reporters = ["dots", "saucelabs"] + + config.sauceLabs = { + testName: "ActionCable JS Client", + retryLimit: 3, + build: buildId(), + } + + function sauce(browserName, version, platform) { + const options = { + base: "SauceLabs", + browserName: browserName.toString(), + version: version.toString(), + } + if (platform) { + options.platform = platform.toString() + } + return options + } + + function buildId() { + const { BUILDKITE_JOB_ID } = process.env + return BUILDKITE_JOB_ID + ? `Buildkite ${BUILDKITE_JOB_ID}` + : "" + } +} + +module.exports = function(karmaConfig) { + karmaConfig.set(config) +} diff --git a/actioncable/lib/action_cable.rb b/actioncable/lib/action_cable.rb new file mode 100644 index 0000000000000..043e1395b1236 --- /dev/null +++ b/actioncable/lib/action_cable.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +#-- +# Copyright (c) 37signals LLC +# +# 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. +#++ + +require "active_support" +require "active_support/rails" +require "zeitwerk" + +# We compute lib this way instead of using __dir__ because __dir__ gives a real +# path, while __FILE__ honors symlinks. If the gem is stored under a symlinked +# directory, this matters. +lib = File.dirname(__FILE__) + +Zeitwerk::Loader.for_gem.tap do |loader| + loader.ignore( + "#{lib}/rails", # Contains generators, templates, docs, etc. + "#{lib}/action_cable/gem_version.rb", + "#{lib}/action_cable/version.rb", + "#{lib}/action_cable/deprecator.rb", + ) + + loader.do_not_eager_load( + "#{lib}/action_cable/subscription_adapter", # Adapters are required and loaded on demand. + "#{lib}/action_cable/test_helper.rb", + Dir["#{lib}/action_cable/**/test_case.rb"] + ) + + loader.inflector.inflect("postgresql" => "PostgreSQL") +end.setup + +# :markup: markdown +# :include: ../README.md +module ActionCable + require_relative "action_cable/version" + require_relative "action_cable/deprecator" + + INTERNAL = { + message_types: { + welcome: "welcome", + disconnect: "disconnect", + ping: "ping", + confirmation: "confirm_subscription", + rejection: "reject_subscription" + }, + disconnect_reasons: { + unauthorized: "unauthorized", + invalid_request: "invalid_request", + server_restart: "server_restart", + remote: "remote" + }, + default_mount_path: "/cable", + protocols: ["actioncable-v1-json", "actioncable-unsupported"].freeze + } + + # Singleton instance of the server + module_function def server + @server ||= ActionCable::Server::Base.new + end +end diff --git a/actioncable/lib/action_cable/channel/base.rb b/actioncable/lib/action_cable/channel/base.rb new file mode 100644 index 0000000000000..6b0ff57e3109e --- /dev/null +++ b/actioncable/lib/action_cable/channel/base.rb @@ -0,0 +1,334 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/rescuable" +require "active_support/parameter_filter" + +module ActionCable + module Channel + # # Action Cable Channel Base + # + # The channel provides the basic structure of grouping behavior into logical + # units when communicating over the WebSocket connection. You can think of a + # channel like a form of controller, but one that's capable of pushing content + # to the subscriber in addition to simply responding to the subscriber's direct + # requests. + # + # Channel instances are long-lived. A channel object will be instantiated when + # the cable consumer becomes a subscriber, and then lives until the consumer + # disconnects. This may be seconds, minutes, hours, or even days. That means you + # have to take special care not to do anything silly in a channel that would + # balloon its memory footprint or whatever. The references are forever, so they + # won't be released as is normally the case with a controller instance that gets + # thrown away after every request. + # + # Long-lived channels (and connections) also mean you're responsible for + # ensuring that the data is fresh. If you hold a reference to a user record, but + # the name is changed while that reference is held, you may be sending stale + # data if you don't take precautions to avoid it. + # + # The upside of long-lived channel instances is that you can use instance + # variables to keep reference to objects that future subscriber requests can + # interact with. Here's a quick example: + # + # class ChatChannel < ApplicationCable::Channel + # def subscribed + # @room = Chat::Room[params[:room_number]] + # end + # + # def speak(data) + # @room.speak data, user: current_user + # end + # end + # + # The #speak action simply uses the Chat::Room object that was created when the + # channel was first subscribed to by the consumer when that subscriber wants to + # say something in the room. + # + # ## Action processing + # + # Unlike subclasses of ActionController::Base, channels do not follow a RESTful + # constraint form for their actions. Instead, Action Cable operates through a + # remote-procedure call model. You can declare any public method on the channel + # (optionally taking a `data` argument), and this method is automatically + # exposed as callable to the client. + # + # Example: + # + # class AppearanceChannel < ApplicationCable::Channel + # def subscribed + # @connection_token = generate_connection_token + # end + # + # def unsubscribed + # current_user.disappear @connection_token + # end + # + # def appear(data) + # current_user.appear @connection_token, on: data['appearing_on'] + # end + # + # def away + # current_user.away @connection_token + # end + # + # private + # def generate_connection_token + # SecureRandom.hex(36) + # end + # end + # + # In this example, the subscribed and unsubscribed methods are not callable + # methods, as they were already declared in ActionCable::Channel::Base, but + # `#appear` and `#away` are. `#generate_connection_token` is also not callable, + # since it's a private method. You'll see that appear accepts a data parameter, + # which it then uses as part of its model call. `#away` does not, since it's + # simply a trigger action. + # + # Also note that in this example, `current_user` is available because it was + # marked as an identifying attribute on the connection. All such identifiers + # will automatically create a delegation method of the same name on the channel + # instance. + # + # ## Rejecting subscription requests + # + # A channel can reject a subscription request in the #subscribed callback by + # invoking the #reject method: + # + # class ChatChannel < ApplicationCable::Channel + # def subscribed + # @room = Chat::Room[params[:room_number]] + # reject unless current_user.can_access?(@room) + # end + # end + # + # In this example, the subscription will be rejected if the `current_user` does + # not have access to the chat room. On the client-side, the `Channel#rejected` + # callback will get invoked when the server rejects the subscription request. + class Base + include Callbacks + include PeriodicTimers + include Streams + include Naming + include Broadcasting + include ActiveSupport::Rescuable + + attr_reader :params, :connection, :identifier + delegate :logger, to: :connection + + class << self + # A list of method names that should be considered actions. This includes all + # public instance methods on a channel, less any internal methods (defined on + # Base), adding back in any methods that are internal, but still exist on the + # class itself. + # + # #### Returns + # * `Set` - A set of all methods that should be considered actions. + def action_methods + @action_methods ||= begin + # All public instance methods of this class, including ancestors + methods = (public_instance_methods(true) - + # Except for public instance methods of Base and its ancestors + ActionCable::Channel::Base.public_instance_methods(true) + + # Be sure to include shadowed public instance methods of this class + public_instance_methods(false)).uniq.map(&:to_s) + methods.to_set + end + end + + private + # action_methods are cached and there is sometimes need to refresh them. + # ::clear_action_methods! allows you to do that, so next time you run + # action_methods, they will be recalculated. + def clear_action_methods! # :doc: + @action_methods = nil + end + + # Refresh the cached action_methods when a new action_method is added. + def method_added(name) # :doc: + super + clear_action_methods! + end + end + + def initialize(connection, identifier, params = {}) + @connection = connection + @identifier = identifier + @params = params + + # When a channel is streaming via pubsub, we want to delay the confirmation + # transmission until pubsub subscription is confirmed. + # + # The counter starts at 1 because it's awaiting a call to #subscribe_to_channel + @defer_subscription_confirmation_counter = Concurrent::AtomicFixnum.new(1) + + @reject_subscription = nil + @subscription_confirmation_sent = nil + + delegate_connection_identifiers + end + + # Extract the action name from the passed data and process it via the channel. + # The process will ensure that the action requested is a public method on the + # channel declared by the user (so not one of the callbacks like #subscribed). + def perform_action(data) + action = extract_action(data) + + if processable_action?(action) + payload = { channel_class: self.class.name, action: action, data: data } + ActiveSupport::Notifications.instrument("perform_action.action_cable", payload) do + dispatch_action(action, data) + end + else + logger.error "Unable to process #{action_signature(action, data)}" + end + end + + # This method is called after subscription has been added to the connection and + # confirms or rejects the subscription. + def subscribe_to_channel + run_callbacks :subscribe do + subscribed + end + + reject_subscription if subscription_rejected? + ensure_confirmation_sent + end + + # Called by the cable connection when it's cut, so the channel has a chance to + # cleanup with callbacks. This method is not intended to be called directly by + # the user. Instead, override the #unsubscribed callback. + def unsubscribe_from_channel # :nodoc: + run_callbacks :unsubscribe do + unsubscribed + end + end + + private + # Called once a consumer has become a subscriber of the channel. Usually the + # place to set up any streams you want this channel to be sending to the + # subscriber. + def subscribed # :doc: + # Override in subclasses + end + + # Called once a consumer has cut its cable connection. Can be used for cleaning + # up connections or marking users as offline or the like. + def unsubscribed # :doc: + # Override in subclasses + end + + # Transmit a hash of data to the subscriber. The hash will automatically be + # wrapped in a JSON envelope with the proper channel identifier marked as the + # recipient. + def transmit(data, via: nil) # :doc: + logger.debug do + status = "#{self.class.name} transmitting #{data.inspect.truncate(300)}" + status += " (via #{via})" if via + status + end + + payload = { channel_class: self.class.name, data: data, via: via } + ActiveSupport::Notifications.instrument("transmit.action_cable", payload) do + connection.transmit identifier: @identifier, message: data + end + end + + def ensure_confirmation_sent # :doc: + return if subscription_rejected? + @defer_subscription_confirmation_counter.decrement + transmit_subscription_confirmation unless defer_subscription_confirmation? + end + + def defer_subscription_confirmation! # :doc: + @defer_subscription_confirmation_counter.increment + end + + def defer_subscription_confirmation? # :doc: + @defer_subscription_confirmation_counter.value > 0 + end + + def subscription_confirmation_sent? # :doc: + @subscription_confirmation_sent + end + + def reject # :doc: + @reject_subscription = true + end + + def subscription_rejected? # :doc: + @reject_subscription + end + + def delegate_connection_identifiers + connection.identifiers.each do |identifier| + define_singleton_method(identifier) do + connection.send(identifier) + end + end + end + + def extract_action(data) + (data["action"].presence || :receive).to_sym + end + + def processable_action?(action) + self.class.action_methods.include?(action.to_s) unless subscription_rejected? + end + + def dispatch_action(action, data) + logger.debug action_signature(action, data) + + if method(action).arity == 1 + public_send action, data + else + public_send action + end + rescue Exception => exception + rescue_with_handler(exception) || raise + end + + def action_signature(action, data) + (+"#{self.class.name}##{action}").tap do |signature| + arguments = data.except("action") + + if arguments.any? + arguments = parameter_filter.filter(arguments) + signature << "(#{arguments.inspect})" + end + end + end + + def parameter_filter + @parameter_filter ||= ActiveSupport::ParameterFilter.new(connection.config.filter_parameters) + end + + def transmit_subscription_confirmation + unless subscription_confirmation_sent? + logger.debug "#{self.class.name} is transmitting the subscription confirmation" + + ActiveSupport::Notifications.instrument("transmit_subscription_confirmation.action_cable", channel_class: self.class.name, identifier: @identifier) do + connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:confirmation] + @subscription_confirmation_sent = true + end + end + end + + def reject_subscription + connection.subscriptions.remove_subscription self + transmit_subscription_rejection + end + + def transmit_subscription_rejection + logger.debug "#{self.class.name} is transmitting the subscription rejection" + + ActiveSupport::Notifications.instrument("transmit_subscription_rejection.action_cable", channel_class: self.class.name, identifier: @identifier) do + connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:rejection] + end + end + end + end +end + +ActiveSupport.run_load_hooks(:action_cable_channel, ActionCable::Channel::Base) diff --git a/actioncable/lib/action_cable/channel/broadcasting.rb b/actioncable/lib/action_cable/channel/broadcasting.rb new file mode 100644 index 0000000000000..1a22e1fb307f3 --- /dev/null +++ b/actioncable/lib/action_cable/channel/broadcasting.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/object/to_param" + +module ActionCable + module Channel + module Broadcasting + extend ActiveSupport::Concern + + module ClassMethods + # Broadcast a hash to a unique broadcasting for this `model` in this channel. + def broadcast_to(model, message) + ActionCable.server.broadcast(broadcasting_for(model), message) + end + + # Returns a unique broadcasting identifier for this `model` in this channel: + # + # CommentsChannel.broadcasting_for("all") # => "comments:all" + # + # You can pass any object as a target (e.g. Active Record model), and it would + # be serialized into a string under the hood. + def broadcasting_for(model) + serialize_broadcasting([ channel_name, model ]) + end + + private + def serialize_broadcasting(object) # :nodoc: + case + when object.is_a?(Array) + object.map { |m| serialize_broadcasting(m) }.join(":") + when object.respond_to?(:to_gid_param) + object.to_gid_param + else + object.to_param + end + end + end + + def broadcasting_for(model) + self.class.broadcasting_for(model) + end + + def broadcast_to(model, message) + self.class.broadcast_to(model, message) + end + end + end +end diff --git a/actioncable/lib/action_cable/channel/callbacks.rb b/actioncable/lib/action_cable/channel/callbacks.rb new file mode 100644 index 0000000000000..5df7bc16943f5 --- /dev/null +++ b/actioncable/lib/action_cable/channel/callbacks.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/callbacks" + +module ActionCable + module Channel + # # Action Cable Channel Callbacks + # + # Action Cable Channel provides callback hooks that are invoked during the life + # cycle of a channel: + # + # * [before_subscribe](rdoc-ref:ClassMethods#before_subscribe) + # * [after_subscribe](rdoc-ref:ClassMethods#after_subscribe) (aliased as + # [on_subscribe](rdoc-ref:ClassMethods#on_subscribe)) + # * [before_unsubscribe](rdoc-ref:ClassMethods#before_unsubscribe) + # * [after_unsubscribe](rdoc-ref:ClassMethods#after_unsubscribe) (aliased as + # [on_unsubscribe](rdoc-ref:ClassMethods#on_unsubscribe)) + # + # + # #### Example + # + # class ChatChannel < ApplicationCable::Channel + # after_subscribe :send_welcome_message, unless: :subscription_rejected? + # after_subscribe :track_subscription + # + # private + # def send_welcome_message + # broadcast_to(...) + # end + # + # def track_subscription + # # ... + # end + # end + # + module Callbacks + extend ActiveSupport::Concern + include ActiveSupport::Callbacks + + included do + define_callbacks :subscribe + define_callbacks :unsubscribe + end + + module ClassMethods + def before_subscribe(*methods, &block) + set_callback(:subscribe, :before, *methods, &block) + end + + # This callback will be triggered after the Base#subscribed method is called, + # even if the subscription was rejected with the Base#reject method. + # + # To trigger the callback only on successful subscriptions, use the + # Base#subscription_rejected? method: + # + # after_subscribe :my_method, unless: :subscription_rejected? + # + def after_subscribe(*methods, &block) + set_callback(:subscribe, :after, *methods, &block) + end + alias_method :on_subscribe, :after_subscribe + + def before_unsubscribe(*methods, &block) + set_callback(:unsubscribe, :before, *methods, &block) + end + + def after_unsubscribe(*methods, &block) + set_callback(:unsubscribe, :after, *methods, &block) + end + alias_method :on_unsubscribe, :after_unsubscribe + end + end + end +end diff --git a/actioncable/lib/action_cable/channel/naming.rb b/actioncable/lib/action_cable/channel/naming.rb new file mode 100644 index 0000000000000..9a17fc514bd2a --- /dev/null +++ b/actioncable/lib/action_cable/channel/naming.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Channel + module Naming + extend ActiveSupport::Concern + + module ClassMethods + # Returns the name of the channel, underscored, without the `Channel` ending. If + # the channel is in a namespace, then the namespaces are represented by single + # colon separators in the channel name. + # + # ChatChannel.channel_name # => 'chat' + # Chats::AppearancesChannel.channel_name # => 'chats:appearances' + # FooChats::BarAppearancesChannel.channel_name # => 'foo_chats:bar_appearances' + def channel_name + @channel_name ||= name.delete_suffix("Channel").gsub("::", ":").underscore + end + end + + def channel_name + self.class.channel_name + end + end + end +end diff --git a/actioncable/lib/action_cable/channel/periodic_timers.rb b/actioncable/lib/action_cable/channel/periodic_timers.rb new file mode 100644 index 0000000000000..2c5a574626547 --- /dev/null +++ b/actioncable/lib/action_cable/channel/periodic_timers.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Channel + module PeriodicTimers + extend ActiveSupport::Concern + + included do + class_attribute :periodic_timers, instance_reader: false, default: [] + + after_subscribe :start_periodic_timers + after_unsubscribe :stop_periodic_timers + end + + module ClassMethods + # Periodically performs a task on the channel, like updating an online user + # counter, polling a backend for new status messages, sending regular + # "heartbeat" messages, or doing some internal work and giving progress updates. + # + # Pass a method name or lambda argument or provide a block to call. Specify the + # calling period in seconds using the `every:` keyword argument. + # + # periodically :transmit_progress, every: 5.seconds + # + # periodically every: 3.minutes do + # transmit action: :update_count, count: current_count + # end + # + def periodically(callback_or_method_name = nil, every:, &block) + callback = + if block_given? + raise ArgumentError, "Pass a block or provide a callback arg, not both" if callback_or_method_name + block + else + case callback_or_method_name + when Proc + callback_or_method_name + when Symbol + -> { __send__ callback_or_method_name } + else + raise ArgumentError, "Expected a Symbol method name or a Proc, got #{callback_or_method_name.inspect}" + end + end + + unless every.kind_of?(Numeric) && every > 0 + raise ArgumentError, "Expected every: to be a positive number of seconds, got #{every.inspect}" + end + + self.periodic_timers += [[ callback, every: every ]] + end + end + + private + def active_periodic_timers + @active_periodic_timers ||= [] + end + + def start_periodic_timers + self.class.periodic_timers.each do |callback, options| + active_periodic_timers << start_periodic_timer(callback, every: options.fetch(:every)) + end + end + + def start_periodic_timer(callback, every:) + connection.server.event_loop.timer every do + connection.worker_pool.async_exec self, connection: connection, &callback + end + end + + def stop_periodic_timers + active_periodic_timers.each { |timer| timer.shutdown } + active_periodic_timers.clear + end + end + end +end diff --git a/actioncable/lib/action_cable/channel/streams.rb b/actioncable/lib/action_cable/channel/streams.rb new file mode 100644 index 0000000000000..7ab3ab20d7a2f --- /dev/null +++ b/actioncable/lib/action_cable/channel/streams.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Channel + # # Action Cable Channel Streams + # + # Streams allow channels to route broadcastings to the subscriber. A + # broadcasting is, as discussed elsewhere, a pubsub queue where any data placed + # into it is automatically sent to the clients that are connected at that time. + # It's purely an online queue, though. If you're not streaming a broadcasting at + # the very moment it sends out an update, you will not get that update, even if + # you connect after it has been sent. + # + # Most commonly, the streamed broadcast is sent straight to the subscriber on + # the client-side. The channel just acts as a connector between the two parties + # (the broadcaster and the channel subscriber). Here's an example of a channel + # that allows subscribers to get all new comments on a given page: + # + # class CommentsChannel < ApplicationCable::Channel + # def follow(data) + # stream_from "comments_for_#{data['recording_id']}" + # end + # + # def unfollow + # stop_all_streams + # end + # end + # + # Based on the above example, the subscribers of this channel will get whatever + # data is put into the, let's say, `comments_for_45` broadcasting as soon as + # it's put there. + # + # An example broadcasting for this channel looks like so: + # + # ActionCable.server.broadcast "comments_for_45", { author: 'DHH', content: 'Rails is just swell' } + # + # If you have a stream that is related to a model, then the broadcasting used + # can be generated from the model and channel. The following example would + # subscribe to a broadcasting like `comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE`. + # + # class CommentsChannel < ApplicationCable::Channel + # def subscribed + # post = Post.find(params[:id]) + # stream_for post + # end + # end + # + # You can then broadcast to this channel using: + # + # CommentsChannel.broadcast_to(@post, @comment) + # + # If you don't just want to parlay the broadcast unfiltered to the subscriber, + # you can also supply a callback that lets you alter what is sent out. The below + # example shows how you can use this to provide performance introspection in the + # process: + # + # class ChatChannel < ApplicationCable::Channel + # def subscribed + # @room = Chat::Room[params[:room_number]] + # + # stream_for @room, coder: ActiveSupport::JSON do |message| + # if message['originated_at'].present? + # elapsed_time = (Time.now.to_f - message['originated_at']).round(2) + # + # ActiveSupport::Notifications.instrument :performance, measurement: 'Chat.message_delay', value: elapsed_time, action: :timing + # logger.info "Message took #{elapsed_time}s to arrive" + # end + # + # transmit message + # end + # end + # end + # + # You can stop streaming from all broadcasts by calling #stop_all_streams. + module Streams + extend ActiveSupport::Concern + + included do + on_unsubscribe :stop_all_streams + end + + # Start streaming from the named `broadcasting` pubsub queue. Optionally, you + # can pass a `callback` that'll be used instead of the default of just + # transmitting the updates straight to the subscriber. Pass `coder: + # ActiveSupport::JSON` to decode messages as JSON before passing to the + # callback. Defaults to `coder: nil` which does no decoding, passes raw + # messages. + def stream_from(broadcasting, callback = nil, coder: nil, &block) + broadcasting = String(broadcasting) + + # Don't send the confirmation until pubsub#subscribe is successful + defer_subscription_confirmation! + + # Build a stream handler by wrapping the user-provided callback with a decoder + # or defaulting to a JSON-decoding retransmitter. + handler = worker_pool_stream_handler(broadcasting, callback || block, coder: coder) + streams[broadcasting] = handler + + connection.server.event_loop.post do + pubsub.subscribe(broadcasting, handler, lambda do + ensure_confirmation_sent + logger.info "#{self.class.name} is streaming from #{broadcasting}" + end) + end + end + + # Start streaming the pubsub queue for the `model` in this channel. Optionally, + # you can pass a `callback` that'll be used instead of the default of just + # transmitting the updates straight to the subscriber. + # + # Pass `coder: ActiveSupport::JSON` to decode messages as JSON before passing to + # the callback. Defaults to `coder: nil` which does no decoding, passes raw + # messages. + def stream_for(model, callback = nil, coder: nil, &block) + stream_from(broadcasting_for(model), callback || block, coder: coder) + end + + # Unsubscribes streams from the named `broadcasting`. + def stop_stream_from(broadcasting) + callback = streams.delete(broadcasting) + if callback + pubsub.unsubscribe(broadcasting, callback) + logger.info "#{self.class.name} stopped streaming from #{broadcasting}" + end + end + + # Unsubscribes streams for the `model`. + def stop_stream_for(model) + stop_stream_from(broadcasting_for(model)) + end + + # Unsubscribes all streams associated with this channel from the pubsub queue. + def stop_all_streams + streams.each do |broadcasting, callback| + pubsub.unsubscribe broadcasting, callback + logger.info "#{self.class.name} stopped streaming from #{broadcasting}" + end.clear + end + + # Calls stream_for with the given `model` if it's present to start streaming, + # otherwise rejects the subscription. + def stream_or_reject_for(model) + if model + stream_for model + else + reject + end + end + + private + delegate :pubsub, to: :connection + + def streams + @_streams ||= {} + end + + # Always wrap the outermost handler to invoke the user handler on the worker + # pool rather than blocking the event loop. + def worker_pool_stream_handler(broadcasting, user_handler, coder: nil) + handler = stream_handler(broadcasting, user_handler, coder: coder) + + -> message do + connection.worker_pool.async_invoke handler, :call, message, connection: connection + end + end + + # May be overridden to add instrumentation, logging, specialized error handling, + # or other forms of handler decoration. + # + # TODO: Tests demonstrating this. + def stream_handler(broadcasting, user_handler, coder: nil) + if user_handler + stream_decoder user_handler, coder: coder + else + default_stream_handler broadcasting, coder: coder + end + end + + # May be overridden to change the default stream handling behavior which decodes + # JSON and transmits to the client. + # + # TODO: Tests demonstrating this. + # + # TODO: Room for optimization. Update transmit API to be coder-aware so we can + # no-op when pubsub and connection are both JSON-encoded. Then we can skip + # decode+encode if we're just proxying messages. + def default_stream_handler(broadcasting, coder:) + coder ||= ActiveSupport::JSON + stream_transmitter stream_decoder(coder: coder), broadcasting: broadcasting + end + + def stream_decoder(handler = identity_handler, coder:) + if coder + -> message { handler.(coder.decode(message)) } + else + handler + end + end + + def stream_transmitter(handler = identity_handler, broadcasting:) + via = "streamed from #{broadcasting}" + + -> (message) do + transmit handler.(message), via: via + end + end + + def identity_handler + -> message { message } + end + end + end +end diff --git a/actioncable/lib/action_cable/channel/test_case.rb b/actioncable/lib/action_cable/channel/test_case.rb new file mode 100644 index 0000000000000..d3b38ca5151bb --- /dev/null +++ b/actioncable/lib/action_cable/channel/test_case.rb @@ -0,0 +1,356 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support" +require "active_support/test_case" +require "active_support/core_ext/hash/indifferent_access" +require "json" + +module ActionCable + module Channel + class NonInferrableChannelError < ::StandardError + def initialize(name) + super "Unable to determine the channel to test from #{name}. " + + "You'll need to specify it using `tests YourChannel` in your " + + "test case definition." + end + end + + # # Action Cable Channel Stub + # + # Stub `stream_from` to track streams for the channel. Add public aliases for + # `subscription_confirmation_sent?` and `subscription_rejected?`. + module ChannelStub + def confirmed? + subscription_confirmation_sent? + end + + def rejected? + subscription_rejected? + end + + def stream_from(broadcasting, *) + streams << broadcasting + end + + def stop_all_streams + @_streams = [] + end + + def streams + @_streams ||= [] + end + + # Make periodic timers no-op + def start_periodic_timers; end + alias stop_periodic_timers start_periodic_timers + end + + class ConnectionStub + attr_reader :server, :transmissions, :identifiers, :subscriptions, :logger + + delegate :pubsub, :config, to: :server + + def initialize(identifiers = {}) + @server = ActionCable.server + @transmissions = [] + + identifiers.each do |identifier, val| + define_singleton_method(identifier) { val } + end + + @subscriptions = ActionCable::Connection::Subscriptions.new(self) + @identifiers = identifiers.keys + @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + end + + def transmit(cable_message) + transmissions << cable_message.with_indifferent_access + end + + def connection_identifier + @connection_identifier ||= connection_gid(identifiers.filter_map { |id| send(id.to_sym) if id }) + end + + private + def connection_gid(ids) + ids.map do |o| + if o.respond_to?(:to_gid_param) + o.to_gid_param + else + o.to_s + end + end.sort.join(":") + end + end + + # Superclass for Action Cable channel functional tests. + # + # ## Basic example + # + # Functional tests are written as follows: + # 1. First, one uses the `subscribe` method to simulate subscription creation. + # 2. Then, one asserts whether the current state is as expected. "State" can be + # anything: transmitted messages, subscribed streams, etc. + # + # + # For example: + # + # class ChatChannelTest < ActionCable::Channel::TestCase + # def test_subscribed_with_room_number + # # Simulate a subscription creation + # subscribe room_number: 1 + # + # # Asserts that the subscription was successfully created + # assert subscription.confirmed? + # + # # Asserts that the channel subscribes connection to a stream + # assert_has_stream "chat_1" + # + # # Asserts that the channel subscribes connection to a specific + # # stream created for a model + # assert_has_stream_for Room.find(1) + # end + # + # def test_does_not_stream_with_incorrect_room_number + # subscribe room_number: -1 + # + # # Asserts that not streams was started + # assert_no_streams + # end + # + # def test_does_not_subscribe_without_room_number + # subscribe + # + # # Asserts that the subscription was rejected + # assert subscription.rejected? + # end + # end + # + # You can also perform actions: + # def test_perform_speak + # subscribe room_number: 1 + # + # perform :speak, message: "Hello, Rails!" + # + # assert_equal "Hello, Rails!", transmissions.last["text"] + # end + # + # ## Special methods + # + # ActionCable::Channel::TestCase will also automatically provide the following + # instance methods for use in the tests: + # + # connection + # : An ActionCable::Channel::ConnectionStub, representing the current HTTP + # connection. + # + # subscription + # : An instance of the current channel, created when you call `subscribe`. + # + # transmissions + # : A list of all messages that have been transmitted into the channel. + # + # + # ## Channel is automatically inferred + # + # ActionCable::Channel::TestCase will automatically infer the channel under test + # from the test class name. If the channel cannot be inferred from the test + # class name, you can explicitly set it with `tests`. + # + # class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase + # tests SpecialChannel + # end + # + # ## Specifying connection identifiers + # + # You need to set up your connection manually to provide values for the + # identifiers. To do this just use: + # + # stub_connection(user: users(:john)) + # + # ## Testing broadcasting + # + # ActionCable::Channel::TestCase enhances ActionCable::TestHelper assertions + # (e.g. `assert_broadcasts`) to handle broadcasting to models: + # + # # in your channel + # def speak(data) + # broadcast_to room, text: data["message"] + # end + # + # def test_speak + # subscribe room_id: rooms(:chat).id + # + # assert_broadcast_on(rooms(:chat), text: "Hello, Rails!") do + # perform :speak, message: "Hello, Rails!" + # end + # end + class TestCase < ActiveSupport::TestCase + module Behavior + extend ActiveSupport::Concern + + include ActiveSupport::Testing::ConstantLookup + include ActionCable::TestHelper + + CHANNEL_IDENTIFIER = "test_stub" + + included do + class_attribute :_channel_class + + attr_reader :connection, :subscription + + ActiveSupport.run_load_hooks(:action_cable_channel_test_case, self) + end + + module ClassMethods + def tests(channel) + case channel + when String, Symbol + self._channel_class = channel.to_s.camelize.constantize + when Module + self._channel_class = channel + else + raise NonInferrableChannelError.new(channel) + end + end + + def channel_class + if channel = self._channel_class + channel + else + tests determine_default_channel(name) + end + end + + def determine_default_channel(name) + channel = determine_constant_from_test_name(name) do |constant| + Class === constant && constant < ActionCable::Channel::Base + end + raise NonInferrableChannelError.new(name) if channel.nil? + channel + end + end + + # Set up test connection with the specified identifiers: + # + # class ApplicationCable < ActionCable::Connection::Base + # identified_by :user, :token + # end + # + # stub_connection(user: users[:john], token: 'my-secret-token') + def stub_connection(identifiers = {}) + @connection = ConnectionStub.new(identifiers) + end + + # Subscribe to the channel under test. Optionally pass subscription parameters + # as a Hash. + def subscribe(params = {}) + @connection ||= stub_connection + @subscription = self.class.channel_class.new(connection, CHANNEL_IDENTIFIER, params.with_indifferent_access) + @subscription.singleton_class.include(ChannelStub) + @subscription.subscribe_to_channel + @subscription + end + + # Unsubscribe the subscription under test. + def unsubscribe + check_subscribed! + subscription.unsubscribe_from_channel + end + + # Perform action on a channel. + # + # NOTE: Must be subscribed. + def perform(action, data = {}) + check_subscribed! + subscription.perform_action(data.stringify_keys.merge("action" => action.to_s)) + end + + # Returns messages transmitted into channel + def transmissions + # Return only directly sent message (via #transmit) + connection.transmissions.filter_map { |data| data["message"] } + end + + # Enhance TestHelper assertions to handle non-String broadcastings + def assert_broadcasts(stream_or_object, *args) + super(broadcasting_for(stream_or_object), *args) + end + + def assert_broadcast_on(stream_or_object, *args) + super(broadcasting_for(stream_or_object), *args) + end + + # Asserts that no streams have been started. + # + # def test_assert_no_started_stream + # subscribe + # assert_no_streams + # end + # + def assert_no_streams + assert subscription.streams.empty?, "No streams started was expected, but #{subscription.streams.count} found" + end + + # Asserts that the specified stream has been started. + # + # def test_assert_started_stream + # subscribe + # assert_has_stream 'messages' + # end + # + def assert_has_stream(stream) + assert subscription.streams.include?(stream), "Stream #{stream} has not been started" + end + + # Asserts that the specified stream for a model has started. + # + # def test_assert_started_stream_for + # subscribe id: 42 + # assert_has_stream_for User.find(42) + # end + # + def assert_has_stream_for(object) + assert_has_stream(broadcasting_for(object)) + end + + # Asserts that the specified stream has not been started. + # + # def test_assert_no_started_stream + # subscribe + # assert_has_no_stream 'messages' + # end + # + def assert_has_no_stream(stream) + assert subscription.streams.exclude?(stream), "Stream #{stream} has been started" + end + + # Asserts that the specified stream for a model has not started. + # + # def test_assert_no_started_stream_for + # subscribe id: 41 + # assert_has_no_stream_for User.find(42) + # end + # + def assert_has_no_stream_for(object) + assert_has_no_stream(broadcasting_for(object)) + end + + private + def check_subscribed! + raise "Must be subscribed!" if subscription.nil? || subscription.rejected? + end + + def broadcasting_for(stream_or_object) + return stream_or_object if stream_or_object.is_a?(String) + + self.class.channel_class.broadcasting_for(stream_or_object) + end + end + + include Behavior + end + end +end diff --git a/actioncable/lib/action_cable/connection/authorization.rb b/actioncable/lib/action_cable/connection/authorization.rb new file mode 100644 index 0000000000000..de996e30517db --- /dev/null +++ b/actioncable/lib/action_cable/connection/authorization.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Connection + module Authorization + class UnauthorizedError < StandardError; end + + # Closes the WebSocket connection if it is open and returns an "unauthorized" + # reason. + def reject_unauthorized_connection + logger.error "An unauthorized connection attempt was rejected" + raise UnauthorizedError + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/base.rb b/actioncable/lib/action_cable/connection/base.rb new file mode 100644 index 0000000000000..5ac3bc646b18f --- /dev/null +++ b/actioncable/lib/action_cable/connection/base.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "action_dispatch" +require "active_support/rescuable" + +module ActionCable + module Connection + # # Action Cable Connection Base + # + # For every WebSocket connection the Action Cable server accepts, a Connection + # object will be instantiated. This instance becomes the parent of all of the + # channel subscriptions that are created from there on. Incoming messages are + # then routed to these channel subscriptions based on an identifier sent by the + # Action Cable consumer. The Connection itself does not deal with any specific + # application logic beyond authentication and authorization. + # + # Here's a basic example: + # + # module ApplicationCable + # class Connection < ActionCable::Connection::Base + # identified_by :current_user + # + # def connect + # self.current_user = find_verified_user + # logger.add_tags current_user.name + # end + # + # def disconnect + # # Any cleanup work needed when the cable connection is cut. + # end + # + # private + # def find_verified_user + # User.find_by_identity(cookies.encrypted[:identity_id]) || + # reject_unauthorized_connection + # end + # end + # end + # + # First, we declare that this connection can be identified by its current_user. + # This allows us to later be able to find all connections established for that + # current_user (and potentially disconnect them). You can declare as many + # identification indexes as you like. Declaring an identification means that an + # attr_accessor is automatically set for that key. + # + # Second, we rely on the fact that the WebSocket connection is established with + # the cookies from the domain being sent along. This makes it easy to use signed + # cookies that were set when logging in via a web interface to authorize the + # WebSocket connection. + # + # Finally, we add a tag to the connection-specific logger with the name of the + # current user to easily distinguish their messages in the log. + # + # Pretty simple, eh? + class Base + include Identification + include InternalChannel + include Authorization + include Callbacks + include ActiveSupport::Rescuable + + attr_reader :server, :env, :subscriptions, :logger, :worker_pool, :protocol + delegate :event_loop, :pubsub, :config, to: :server + + def initialize(server, env, coder: ActiveSupport::JSON) + @server, @env, @coder = server, env, coder + + @worker_pool = server.worker_pool + @logger = new_tagged_logger + + @websocket = ActionCable::Connection::WebSocket.new(env, self, event_loop) + @subscriptions = ActionCable::Connection::Subscriptions.new(self) + @message_buffer = ActionCable::Connection::MessageBuffer.new(self) + + @_internal_subscriptions = nil + @started_at = Time.now + end + + # Called by the server when a new WebSocket connection is established. This + # configures the callbacks intended for overwriting by the user. This method + # should not be called directly -- instead rely upon on the #connect (and + # #disconnect) callbacks. + def process # :nodoc: + logger.info started_request_message + + if websocket.possible? && allow_request_origin? + respond_to_successful_request + else + respond_to_invalid_request + end + end + + # Decodes WebSocket messages and dispatches them to subscribed channels. + # WebSocket message transfer encoding is always JSON. + def receive(websocket_message) # :nodoc: + send_async :dispatch_websocket_message, websocket_message + end + + def dispatch_websocket_message(websocket_message) # :nodoc: + if websocket.alive? + handle_channel_command decode(websocket_message) + else + logger.error "Ignoring message processed after the WebSocket was closed: #{websocket_message.inspect})" + end + end + + def handle_channel_command(payload) + run_callbacks :command do + subscriptions.execute_command payload + end + end + + def transmit(cable_message) # :nodoc: + websocket.transmit encode(cable_message) + end + + # Close the WebSocket connection. + def close(reason: nil, reconnect: true) + transmit( + type: ActionCable::INTERNAL[:message_types][:disconnect], + reason: reason, + reconnect: reconnect + ) + websocket.close + end + + # Invoke a method on the connection asynchronously through the pool of thread + # workers. + def send_async(method, *arguments) + worker_pool.async_invoke(self, method, *arguments) + end + + # Return a basic hash of statistics for the connection keyed with `identifier`, + # `started_at`, `subscriptions`, and `request_id`. This can be returned by a + # health check against the connection. + def statistics + { + identifier: connection_identifier, + started_at: @started_at, + subscriptions: subscriptions.identifiers, + request_id: @env["action_dispatch.request_id"] + } + end + + def beat + transmit type: ActionCable::INTERNAL[:message_types][:ping], message: Time.now.to_i + end + + def on_open # :nodoc: + send_async :handle_open + end + + def on_message(message) # :nodoc: + message_buffer.append message + end + + def on_error(message) # :nodoc: + # log errors to make diagnosing socket errors easier + logger.error "WebSocket error occurred: #{message}" + end + + def on_close(reason, code) # :nodoc: + send_async :handle_close + end + + def inspect # :nodoc: + "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>" + end + + private + attr_reader :websocket + attr_reader :message_buffer + + # The request that initiated the WebSocket connection is available here. This + # gives access to the environment, cookies, etc. + def request # :doc: + @request ||= begin + environment = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application + ActionDispatch::Request.new(environment || env) + end + end + + # The cookies of the request that initiated the WebSocket connection. Useful for + # performing authorization checks. + def cookies # :doc: + request.cookie_jar + end + + def encode(cable_message) + @coder.encode cable_message + end + + def decode(websocket_message) + @coder.decode websocket_message + end + + def handle_open + @protocol = websocket.protocol + connect if respond_to?(:connect) + subscribe_to_internal_channel + send_welcome_message + + message_buffer.process! + server.add_connection(self) + rescue ActionCable::Connection::Authorization::UnauthorizedError + close(reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized], reconnect: false) if websocket.alive? + end + + def handle_close + logger.info finished_request_message + + server.remove_connection(self) + + subscriptions.unsubscribe_from_all + unsubscribe_from_internal_channel + + disconnect if respond_to?(:disconnect) + end + + def send_welcome_message + # Send welcome message to the internal connection monitor channel. This ensures + # the connection monitor state is reset after a successful websocket connection. + transmit type: ActionCable::INTERNAL[:message_types][:welcome] + end + + def allow_request_origin? + return true if server.config.disable_request_forgery_protection + + proto = Rack::Request.new(env).ssl? ? "https" : "http" + if server.config.allow_same_origin_as_host && env["HTTP_ORIGIN"] == "#{proto}://#{env['HTTP_HOST']}" + true + elsif Array(server.config.allowed_request_origins).any? { |allowed_origin| allowed_origin === env["HTTP_ORIGIN"] } + true + else + logger.error("Request origin not allowed: #{env['HTTP_ORIGIN']}") + false + end + end + + def respond_to_successful_request + logger.info successful_request_message + websocket.rack_response + end + + def respond_to_invalid_request + close(reason: ActionCable::INTERNAL[:disconnect_reasons][:invalid_request]) if websocket.alive? + + logger.error invalid_request_message + logger.info finished_request_message + [ 404, { Rack::CONTENT_TYPE => "text/plain; charset=utf-8" }, [ "Page not found" ] ] + end + + # Tags are declared in the server but computed in the connection. This allows us + # per-connection tailored tags. + def new_tagged_logger + TaggedLoggerProxy.new server.logger, + tags: server.config.log_tags.map { |tag| tag.respond_to?(:call) ? tag.call(request) : tag.to_s.camelize } + end + + def started_request_message + 'Started %s "%s"%s for %s at %s' % [ + request.request_method, + request.filtered_path, + websocket.possible? ? " [WebSocket]" : "[non-WebSocket]", + request.ip, + Time.now.to_s ] + end + + def finished_request_message + 'Finished "%s"%s for %s at %s' % [ + request.filtered_path, + websocket.possible? ? " [WebSocket]" : "[non-WebSocket]", + request.ip, + Time.now.to_s ] + end + + def invalid_request_message + "Failed to upgrade to WebSocket (REQUEST_METHOD: %s, HTTP_CONNECTION: %s, HTTP_UPGRADE: %s)" % [ + env["REQUEST_METHOD"], env["HTTP_CONNECTION"], env["HTTP_UPGRADE"] + ] + end + + def successful_request_message + "Successfully upgraded to WebSocket (REQUEST_METHOD: %s, HTTP_CONNECTION: %s, HTTP_UPGRADE: %s)" % [ + env["REQUEST_METHOD"], env["HTTP_CONNECTION"], env["HTTP_UPGRADE"] + ] + end + end + end +end + +ActiveSupport.run_load_hooks(:action_cable_connection, ActionCable::Connection::Base) diff --git a/actioncable/lib/action_cable/connection/callbacks.rb b/actioncable/lib/action_cable/connection/callbacks.rb new file mode 100644 index 0000000000000..85a27c6f9f5f6 --- /dev/null +++ b/actioncable/lib/action_cable/connection/callbacks.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/callbacks" + +module ActionCable + module Connection + # # Action Cable Connection Callbacks + # + # The [before_command](rdoc-ref:ClassMethods#before_command), + # [after_command](rdoc-ref:ClassMethods#after_command), and + # [around_command](rdoc-ref:ClassMethods#around_command) callbacks are invoked + # when sending commands to the client, such as when subscribing, unsubscribing, + # or performing an action. + # + # #### Example + # + # module ApplicationCable + # class Connection < ActionCable::Connection::Base + # identified_by :user + # + # around_command :set_current_account + # + # private + # + # def set_current_account + # # Now all channels could use Current.account + # Current.set(account: user.account) { yield } + # end + # end + # end + # + module Callbacks + extend ActiveSupport::Concern + include ActiveSupport::Callbacks + + included do + define_callbacks :command + end + + module ClassMethods + def before_command(*methods, &block) + set_callback(:command, :before, *methods, &block) + end + + def after_command(*methods, &block) + set_callback(:command, :after, *methods, &block) + end + + def around_command(*methods, &block) + set_callback(:command, :around, *methods, &block) + end + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/client_socket.rb b/actioncable/lib/action_cable/connection/client_socket.rb new file mode 100644 index 0000000000000..9d8be5e92fe06 --- /dev/null +++ b/actioncable/lib/action_cable/connection/client_socket.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "websocket/driver" + +module ActionCable + module Connection + #-- + # This class is heavily based on faye-websocket-ruby + # + # Copyright (c) 2010-2015 James Coglan + class ClientSocket # :nodoc: + def self.determine_url(http://github.com/env) + scheme = secure_request?(env) ? "wss:" : "ws:" + "#{ scheme }//#{ env['HTTP_HOST'] }#{ env['REQUEST_URI'] }" + end + + def self.secure_request?(env) + return true if env["HTTPS"] == "on" + return true if env["HTTP_X_FORWARDED_SSL"] == "on" + return true if env["HTTP_X_FORWARDED_SCHEME"] == "https" + return true if env["HTTP_X_FORWARDED_PROTO"] == "https" + return true if env["rack.url_scheme"] == "https" + + false + end + + CONNECTING = 0 + OPEN = 1 + CLOSING = 2 + CLOSED = 3 + + attr_reader :env, :url + + def initialize(env, event_target, event_loop, protocols) + @env = env + @event_target = event_target + @event_loop = event_loop + + @url = ClientSocket.determine_url(http://github.com/@env) + + @driver = @driver_started = nil + @close_params = ["", 1006] + + @ready_state = CONNECTING + + # The driver calls `env`, `url`, and `write` + @driver = ::WebSocket::Driver.rack(self, protocols: protocols) + + @driver.on(:open) { |e| open } + @driver.on(:message) { |e| receive_message(e.data) } + @driver.on(:close) { |e| begin_close(e.reason, e.code) } + @driver.on(:error) { |e| emit_error(e.message) } + + @stream = ActionCable::Connection::Stream.new(@event_loop, self) + end + + def start_driver + return if @driver.nil? || @driver_started + @stream.hijack_rack_socket + + if callback = @env["async.callback"] + callback.call([101, {}, @stream]) + end + + @driver_started = true + @driver.start + end + + def rack_response + start_driver + [ -1, {}, [] ] + end + + def write(data) + @stream.write(data) + rescue => e + emit_error e.message + end + + def transmit(message) + return false if @ready_state > OPEN + case message + when Numeric then @driver.text(message.to_s) + when String then @driver.text(message) + when Array then @driver.binary(message) + else false + end + end + + def close(code = nil, reason = nil) + code ||= 1000 + reason ||= "" + + unless code == 1000 || (code >= 3000 && code <= 4999) + raise ArgumentError, "Failed to execute 'close' on WebSocket: " \ + "The code must be either 1000, or between 3000 and 4999. " \ + "#{code} is neither." + end + + @ready_state = CLOSING unless @ready_state == CLOSED + @driver.close(reason, code) + end + + def parse(data) + @driver.parse(data) + end + + def client_gone + finalize_close + end + + def alive? + @ready_state == OPEN + end + + def protocol + @driver.protocol + end + + private + def open + return unless @ready_state == CONNECTING + @ready_state = OPEN + + @event_target.on_open + end + + def receive_message(data) + return unless @ready_state == OPEN + + @event_target.on_message(data) + end + + def emit_error(message) + return if @ready_state >= CLOSING + + @event_target.on_error(message) + end + + def begin_close(reason, code) + return if @ready_state == CLOSED + @ready_state = CLOSING + @close_params = [reason, code] + + @stream.shutdown if @stream + finalize_close + end + + def finalize_close + return if @ready_state == CLOSED + @ready_state = CLOSED + + @event_target.on_close(*@close_params) + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/identification.rb b/actioncable/lib/action_cable/connection/identification.rb new file mode 100644 index 0000000000000..663fba60ac24e --- /dev/null +++ b/actioncable/lib/action_cable/connection/identification.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Connection + module Identification + extend ActiveSupport::Concern + + included do + class_attribute :identifiers, default: Set.new + end + + module ClassMethods + # Mark a key as being a connection identifier index that can then be used to + # find the specific connection again later. Common identifiers are current_user + # and current_account, but could be anything, really. + # + # Note that anything marked as an identifier will automatically create a + # delegate by the same name on any channel instances created off the connection. + def identified_by(*identifiers) + Array(identifiers).each { |identifier| attr_accessor identifier } + self.identifiers += identifiers + end + end + + # Return a single connection identifier that combines the value of all the + # registered identifiers into a single gid. + def connection_identifier + unless defined? @connection_identifier + @connection_identifier = connection_gid identifiers.filter_map { |id| instance_variable_get("@#{id}") } + end + + @connection_identifier + end + + private + def connection_gid(ids) + ids.map do |o| + if o.respond_to? :to_gid_param + o.to_gid_param + else + o.to_s + end + end.sort.join(":") + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/internal_channel.rb b/actioncable/lib/action_cable/connection/internal_channel.rb new file mode 100644 index 0000000000000..23933e8660bea --- /dev/null +++ b/actioncable/lib/action_cable/connection/internal_channel.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Connection + # # Action Cable InternalChannel + # + # Makes it possible for the RemoteConnection to disconnect a specific + # connection. + module InternalChannel + extend ActiveSupport::Concern + + private + def internal_channel + "action_cable/#{connection_identifier}" + end + + def subscribe_to_internal_channel + if connection_identifier.present? + callback = -> (message) { process_internal_message decode(message) } + @_internal_subscriptions ||= [] + @_internal_subscriptions << [ internal_channel, callback ] + + server.event_loop.post { pubsub.subscribe(internal_channel, callback) } + logger.info "Registered connection (#{connection_identifier})" + end + end + + def unsubscribe_from_internal_channel + if @_internal_subscriptions.present? + @_internal_subscriptions.each { |channel, callback| server.event_loop.post { pubsub.unsubscribe(channel, callback) } } + end + end + + def process_internal_message(message) + case message["type"] + when "disconnect" + logger.info "Removing connection (#{connection_identifier})" + close(reason: ActionCable::INTERNAL[:disconnect_reasons][:remote], reconnect: message.fetch("http://github.com/reconnect", true)) + end + rescue Exception => e + logger.error "There was an exception - #{e.class}(#{e.message})" + logger.error e.backtrace.join("\n") + + close + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/message_buffer.rb b/actioncable/lib/action_cable/connection/message_buffer.rb new file mode 100644 index 0000000000000..35813930244a4 --- /dev/null +++ b/actioncable/lib/action_cable/connection/message_buffer.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Connection + # Allows us to buffer messages received from the WebSocket before the Connection + # has been fully initialized, and is ready to receive them. + class MessageBuffer # :nodoc: + def initialize(connection) + @connection = connection + @buffered_messages = [] + end + + def append(message) + if valid? message + if processing? + receive message + else + buffer message + end + else + connection.logger.error "Couldn't handle non-string message: #{message.class}" + end + end + + def processing? + @processing + end + + def process! + @processing = true + receive_buffered_messages + end + + private + attr_reader :connection + attr_reader :buffered_messages + + def valid?(message) + message.is_a?(String) + end + + def receive(message) + connection.receive message + end + + def buffer(message) + buffered_messages << message + end + + def receive_buffered_messages + receive buffered_messages.shift until buffered_messages.empty? + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/stream.rb b/actioncable/lib/action_cable/connection/stream.rb new file mode 100644 index 0000000000000..01c2d114ec199 --- /dev/null +++ b/actioncable/lib/action_cable/connection/stream.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Connection + #-- + # This class is heavily based on faye-websocket-ruby + # + # Copyright (c) 2010-2015 James Coglan + class Stream # :nodoc: + def initialize(event_loop, socket) + @event_loop = event_loop + @socket_object = socket + @stream_send = socket.env["stream.send"] + + @rack_hijack_io = nil + @write_lock = Mutex.new + + @write_head = nil + @write_buffer = Queue.new + end + + def each(&callback) + @stream_send ||= callback + end + + def close + shutdown + @socket_object.client_gone + end + + def shutdown + clean_rack_hijack + end + + def write(data) + if @stream_send + return @stream_send.call(data) + end + + if @write_lock.try_lock + begin + if @write_head.nil? && @write_buffer.empty? + written = @rack_hijack_io.write_nonblock(data, exception: false) + + case written + when :wait_writable + # proceed below + when data.bytesize + return data.bytesize + else + @write_head = data.byteslice(written, data.bytesize) + @event_loop.writes_pending @rack_hijack_io + + return data.bytesize + end + end + ensure + @write_lock.unlock + end + end + + @write_buffer << data + @event_loop.writes_pending @rack_hijack_io + + data.bytesize + rescue EOFError, Errno::ECONNRESET + @socket_object.client_gone + end + + def flush_write_buffer + @write_lock.synchronize do + loop do + if @write_head.nil? + return true if @write_buffer.empty? + @write_head = @write_buffer.pop + end + + written = @rack_hijack_io.write_nonblock(@write_head, exception: false) + case written + when :wait_writable + return false + when @write_head.bytesize + @write_head = nil + else + @write_head = @write_head.byteslice(written, @write_head.bytesize) + return false + end + end + end + end + + def receive(data) + @socket_object.parse(data) + end + + def hijack_rack_socket + return unless @socket_object.env["rack.hijack"] + + # This should return the underlying io according to the SPEC: + @rack_hijack_io = @socket_object.env["rack.hijack"].call + # Retain existing behavior if required: + @rack_hijack_io ||= @socket_object.env["rack.hijack_io"] + + @event_loop.attach(@rack_hijack_io, self) + end + + private + def clean_rack_hijack + return unless @rack_hijack_io + @event_loop.detach(@rack_hijack_io, self) + @rack_hijack_io = nil + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/stream_event_loop.rb b/actioncable/lib/action_cable/connection/stream_event_loop.rb new file mode 100644 index 0000000000000..38e9823b7e06b --- /dev/null +++ b/actioncable/lib/action_cable/connection/stream_event_loop.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "nio" + +module ActionCable + module Connection + class StreamEventLoop + def initialize + @nio = @executor = @thread = nil + @map = {} + @stopping = false + @todo = Queue.new + + @spawn_mutex = Mutex.new + end + + def timer(interval, &block) + Concurrent::TimerTask.new(execution_interval: interval, &block).tap(&:execute) + end + + def post(task = nil, &block) + task ||= block + + spawn + @executor << task + end + + def attach(io, stream) + @todo << lambda do + @map[io] = @nio.register(io, :r) + @map[io].value = stream + end + wakeup + end + + def detach(io, stream) + @todo << lambda do + @nio.deregister io + @map.delete io + io.close + end + wakeup + end + + def writes_pending(io) + @todo << lambda do + if monitor = @map[io] + monitor.interests = :rw + end + end + wakeup + end + + def stop + @stopping = true + wakeup if @nio + end + + private + def spawn + return if @thread && @thread.status + + @spawn_mutex.synchronize do + return if @thread && @thread.status + + @nio ||= NIO::Selector.new + + @executor ||= Concurrent::ThreadPoolExecutor.new( + name: "ActionCable-streamer", + min_threads: 1, + max_threads: 10, + max_queue: 0, + ) + + @thread = Thread.new { run } + + return true + end + end + + def wakeup + spawn || @nio.wakeup + end + + def run + loop do + if @stopping + @nio.close + break + end + + until @todo.empty? + @todo.pop(true).call + end + + next unless monitors = @nio.select + + monitors.each do |monitor| + io = monitor.io + stream = monitor.value + + begin + if monitor.writable? + if stream.flush_write_buffer + monitor.interests = :r + end + next unless monitor.readable? + end + + incoming = io.read_nonblock(4096, exception: false) + case incoming + when :wait_readable + next + when nil + stream.close + else + stream.receive incoming + end + rescue + # We expect one of EOFError or Errno::ECONNRESET in normal operation (when the + # client goes away). But if anything else goes wrong, this is still the best way + # to handle it. + begin + stream.close + rescue + @nio.deregister io + @map.delete io + end + end + end + end + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/subscriptions.rb b/actioncable/lib/action_cable/connection/subscriptions.rb new file mode 100644 index 0000000000000..a9b1bca7cbc7a --- /dev/null +++ b/actioncable/lib/action_cable/connection/subscriptions.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/hash/indifferent_access" + +module ActionCable + module Connection + # # Action Cable Connection Subscriptions + # + # Collection class for all the channel subscriptions established on a given + # connection. Responsible for routing incoming commands that arrive on the + # connection to the proper channel. + class Subscriptions # :nodoc: + def initialize(connection) + @connection = connection + @subscriptions = {} + end + + def execute_command(data) + case data["command"] + when "subscribe" then add data + when "unsubscribe" then remove data + when "message" then perform_action data + else + logger.error "Received unrecognized command in #{data.inspect}" + end + rescue Exception => e + @connection.rescue_with_handler(e) + logger.error "Could not execute command from (#{data.inspect}) [#{e.class} - #{e.message}]: #{e.backtrace.first(5).join(" | ")}" + end + + def add(data) + id_key = data["identifier"] + id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access + + return if subscriptions.key?(id_key) + + subscription_klass = id_options[:channel].safe_constantize + + if subscription_klass && ActionCable::Channel::Base > subscription_klass + subscription = subscription_klass.new(connection, id_key, id_options) + subscriptions[id_key] = subscription + subscription.subscribe_to_channel + else + logger.error "Subscription class not found: #{id_options[:channel].inspect}" + end + end + + def remove(data) + logger.info "Unsubscribing from channel: #{data['identifier']}" + remove_subscription find(data) + end + + def remove_subscription(subscription) + subscription.unsubscribe_from_channel + subscriptions.delete(subscription.identifier) + end + + def perform_action(data) + find(data).perform_action ActiveSupport::JSON.decode(data["data"]) + end + + def identifiers + subscriptions.keys + end + + def unsubscribe_from_all + subscriptions.each { |id, channel| remove_subscription(channel) } + end + + private + attr_reader :connection, :subscriptions + delegate :logger, to: :connection + + def find(data) + if subscription = subscriptions[data["identifier"]] + subscription + else + raise "Unable to find subscription with identifier: #{data['identifier']}" + end + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb b/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb new file mode 100644 index 0000000000000..b7d97afb09eee --- /dev/null +++ b/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Connection + # # Action Cable Connection TaggedLoggerProxy + # + # Allows the use of per-connection tags against the server logger. This wouldn't + # work using the traditional ActiveSupport::TaggedLogging enhanced Rails.logger, + # as that logger will reset the tags between requests. The connection is + # long-lived, so it needs its own set of tags for its independent duration. + class TaggedLoggerProxy + attr_reader :tags + + def initialize(logger, tags:) + @logger = logger + @tags = tags.flatten + end + + def add_tags(*tags) + @tags += tags.flatten + @tags = @tags.uniq + end + + def tag(logger, &block) + if logger.respond_to?(:tagged) + current_tags = tags - logger.formatter.current_tags + logger.tagged(*current_tags, &block) + else + yield + end + end + + %i( debug info warn error fatal unknown ).each do |severity| + define_method(severity) do |message = nil, &block| + log severity, message, &block + end + end + + private + def log(type, message, &block) # :doc: + tag(@logger) { @logger.send type, message, &block } + end + end + end +end diff --git a/actioncable/lib/action_cable/connection/test_case.rb b/actioncable/lib/action_cable/connection/test_case.rb new file mode 100644 index 0000000000000..5eeb0775fd7b3 --- /dev/null +++ b/actioncable/lib/action_cable/connection/test_case.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support" +require "active_support/test_case" +require "active_support/core_ext/hash/indifferent_access" +require "action_dispatch" +require "action_dispatch/http/headers" +require "action_dispatch/testing/test_request" + +module ActionCable + module Connection + class NonInferrableConnectionError < ::StandardError + def initialize(name) + super "Unable to determine the connection to test from #{name}. " + + "You'll need to specify it using `tests YourConnection` in your " + + "test case definition." + end + end + + module Assertions + # Asserts that the connection is rejected (via + # `reject_unauthorized_connection`). + # + # # Asserts that connection without user_id fails + # assert_reject_connection { connect params: { user_id: '' } } + def assert_reject_connection(&block) + assert_raises(Authorization::UnauthorizedError, "Expected to reject connection but no rejection was made", &block) + end + end + + class TestCookies < ActiveSupport::HashWithIndifferentAccess # :nodoc: + def []=(name, options) + value = options.is_a?(Hash) ? options.symbolize_keys[:value] : options + super(name, value) + end + end + + # We don't want to use the whole "encryption stack" for connection unit-tests, + # but we want to make sure that users test against the correct types of cookies + # (i.e. signed or encrypted or plain) + class TestCookieJar < TestCookies + def signed + @signed ||= TestCookies.new + end + + def encrypted + @encrypted ||= TestCookies.new + end + end + + class TestRequest < ActionDispatch::TestRequest + attr_accessor :session, :cookie_jar + end + + module TestConnection + attr_reader :logger, :request + + def initialize(request) + inner_logger = ActiveSupport::Logger.new(StringIO.new) + tagged_logging = ActiveSupport::TaggedLogging.new(inner_logger) + @logger = ActionCable::Connection::TaggedLoggerProxy.new(tagged_logging, tags: []) + @request = request + @env = request.env + end + end + + # # Action Cable Connection TestCase + # + # Unit test Action Cable connections. + # + # Useful to check whether a connection's `identified_by` gets assigned properly + # and that any improper connection requests are rejected. + # + # ## Basic example + # + # Unit tests are written by first simulating a connection attempt by calling + # `connect` and then asserting state, e.g. identifiers, have been assigned. + # + # class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase + # def test_connects_with_proper_cookie + # # Simulate the connection request with a cookie. + # cookies["user_id"] = users(:john).id + # + # connect + # + # # Assert the connection identifier matches the fixture. + # assert_equal users(:john).id, connection.user.id + # end + # + # def test_rejects_connection_without_proper_cookie + # assert_reject_connection { connect } + # end + # end + # + # `connect` accepts additional information about the HTTP request with the + # `params`, `headers`, `session`, and Rack `env` options. + # + # def test_connect_with_headers_and_query_string + # connect params: { user_id: 1 }, headers: { "X-API-TOKEN" => "secret-my" } + # + # assert_equal "1", connection.user.id + # assert_equal "secret-my", connection.token + # end + # + # def test_connect_with_params + # connect params: { user_id: 1 } + # + # assert_equal "1", connection.user.id + # end + # + # You can also set up the correct cookies before the connection request: + # + # def test_connect_with_cookies + # # Plain cookies: + # cookies["user_id"] = 1 + # + # # Or signed/encrypted: + # # cookies.signed["user_id"] = 1 + # # cookies.encrypted["user_id"] = 1 + # + # connect + # + # assert_equal "1", connection.user_id + # end + # + # ## Connection is automatically inferred + # + # ActionCable::Connection::TestCase will automatically infer the connection + # under test from the test class name. If the channel cannot be inferred from + # the test class name, you can explicitly set it with `tests`. + # + # class ConnectionTest < ActionCable::Connection::TestCase + # tests ApplicationCable::Connection + # end + # + class TestCase < ActiveSupport::TestCase + module Behavior + extend ActiveSupport::Concern + + DEFAULT_PATH = "/cable" + + include ActiveSupport::Testing::ConstantLookup + include Assertions + + included do + class_attribute :_connection_class + + attr_reader :connection + + ActiveSupport.run_load_hooks(:action_cable_connection_test_case, self) + end + + module ClassMethods + def tests(connection) + case connection + when String, Symbol + self._connection_class = connection.to_s.camelize.constantize + when Module + self._connection_class = connection + else + raise NonInferrableConnectionError.new(connection) + end + end + + def connection_class + if connection = self._connection_class + connection + else + tests determine_default_connection(name) + end + end + + def determine_default_connection(name) + connection = determine_constant_from_test_name(name) do |constant| + Class === constant && constant < ActionCable::Connection::Base + end + raise NonInferrableConnectionError.new(name) if connection.nil? + connection + end + end + + # Performs connection attempt to exert #connect on the connection under test. + # + # Accepts request path as the first argument and the following request options: + # + # * params – URL parameters (Hash) + # * headers – request headers (Hash) + # * session – session data (Hash) + # * env – additional Rack env configuration (Hash) + def connect(path = ActionCable.server.config.mount_path, **request_params) + path ||= DEFAULT_PATH + + connection = self.class.connection_class.allocate + connection.singleton_class.include(TestConnection) + connection.send(:initialize, build_test_request(path, **request_params)) + connection.connect if connection.respond_to?(:connect) + + # Only set instance variable if connected successfully + @connection = connection + end + + # Exert #disconnect on the connection under test. + def disconnect + raise "Must be connected!" if connection.nil? + + connection.disconnect if connection.respond_to?(:disconnect) + @connection = nil + end + + def cookies + @cookie_jar ||= TestCookieJar.new + end + + private + def build_test_request(path, params: nil, headers: {}, session: {}, env: {}) + wrapped_headers = ActionDispatch::Http::Headers.from_hash(headers) + + uri = URI.parse(path) + + query_string = params.nil? ? uri.query : params.to_query + + request_env = { + "QUERY_STRING" => query_string, + "PATH_INFO" => uri.path + }.merge(env) + + if wrapped_headers.present? + ActionDispatch::Http::Headers.from_hash(request_env).merge!(wrapped_headers) + end + + TestRequest.create(request_env).tap do |request| + request.session = session.with_indifferent_access + request.cookie_jar = cookies + end + end + end + + include Behavior + end + end +end diff --git a/actioncable/lib/action_cable/connection/web_socket.rb b/actioncable/lib/action_cable/connection/web_socket.rb new file mode 100644 index 0000000000000..662f5fbb159f6 --- /dev/null +++ b/actioncable/lib/action_cable/connection/web_socket.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "websocket/driver" + +module ActionCable + module Connection + # # Action Cable Connection WebSocket + # + # Wrap the real socket to minimize the externally-presented API + class WebSocket # :nodoc: + def initialize(env, event_target, event_loop, protocols: ActionCable::INTERNAL[:protocols]) + @websocket = ::WebSocket::Driver.websocket?(env) ? ClientSocket.new(env, event_target, event_loop, protocols) : nil + end + + def possible? + websocket + end + + def alive? + websocket&.alive? + end + + def transmit(...) + websocket&.transmit(...) + end + + def close(...) + websocket&.close(...) + end + + def protocol + websocket&.protocol + end + + def rack_response + websocket&.rack_response + end + + private + attr_reader :websocket + end + end +end diff --git a/actioncable/lib/action_cable/deprecator.rb b/actioncable/lib/action_cable/deprecator.rb new file mode 100644 index 0000000000000..b2e74e8ee8e83 --- /dev/null +++ b/actioncable/lib/action_cable/deprecator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + def self.deprecator # :nodoc: + @deprecator ||= ActiveSupport::Deprecation.new + end +end diff --git a/actioncable/lib/action_cable/engine.rb b/actioncable/lib/action_cable/engine.rb new file mode 100644 index 0000000000000..d8a92d28b650f --- /dev/null +++ b/actioncable/lib/action_cable/engine.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "rails" +require "action_cable" +require "active_support/core_ext/hash/indifferent_access" + +module ActionCable + class Engine < Rails::Engine # :nodoc: + config.action_cable = ActiveSupport::OrderedOptions.new + config.action_cable.mount_path = ActionCable::INTERNAL[:default_mount_path] + config.action_cable.precompile_assets = true + + initializer "action_cable.deprecator", before: :load_environment_config do |app| + app.deprecators[:action_cable] = ActionCable.deprecator + end + + initializer "action_cable.helpers" do + ActiveSupport.on_load(:action_view) do + include ActionCable::Helpers::ActionCableHelper + end + end + + initializer "action_cable.logger" do + ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger } + end + + initializer "action_cable.health_check_application" do + ActiveSupport.on_load(:action_cable) { + self.health_check_application = ->(env) { Rails::HealthController.action(:show).call(env) } + } + end + + initializer "action_cable.asset" do + config.after_initialize do |app| + if app.config.respond_to?(:assets) && app.config.action_cable.precompile_assets + app.config.assets.precompile += %w( actioncable.js actioncable.esm.js ) + end + end + end + + initializer "action_cable.set_configs" do |app| + options = app.config.action_cable + options.allowed_request_origins ||= /https?:\/\/localhost:\d+/ if ::Rails.env.development? + + app.paths.add "config/cable", with: "config/cable.yml" + + ActiveSupport.on_load(:action_cable) do + if (config_path = Pathname.new(app.config.paths["config/cable"].first)).exist? + self.cable = app.config_for(config_path).to_h.with_indifferent_access + end + + previous_connection_class = connection_class + self.connection_class = -> { "ApplicationCable::Connection".safe_constantize || previous_connection_class.call } + self.filter_parameters += app.config.filter_parameters + + options.each { |k, v| send("#{k}=", v) } + end + end + + initializer "action_cable.routes" do + config.after_initialize do |app| + config = app.config + unless config.action_cable.mount_path.nil? + app.routes.prepend do + mount ActionCable.server => config.action_cable.mount_path, internal: true, anchor: true + end + end + end + end + + initializer "action_cable.set_work_hooks" do |app| + ActiveSupport.on_load(:action_cable) do + ActionCable::Server::Worker.set_callback :work, :around, prepend: true do |_, inner| + app.executor.wrap(source: "application.action_cable") do + # If we took a while to get the lock, we may have been halted in the meantime. + # As we haven't started doing any real work yet, we should pretend that we never + # made it off the queue. + unless stopping? + inner.call + end + end + end + + wrap = lambda do |_, inner| + app.executor.wrap(source: "application.action_cable", &inner) + end + ActionCable::Channel::Base.set_callback :subscribe, :around, prepend: true, &wrap + ActionCable::Channel::Base.set_callback :unsubscribe, :around, prepend: true, &wrap + + app.reloader.before_class_unload do + ActionCable.server.restart + end + end + end + end +end diff --git a/actioncable/lib/action_cable/gem_version.rb b/actioncable/lib/action_cable/gem_version.rb new file mode 100644 index 0000000000000..fd6a9c80aabce --- /dev/null +++ b/actioncable/lib/action_cable/gem_version.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + # Returns the currently loaded version of Action Cable as a `Gem::Version`. + def self.gem_version + Gem::Version.new VERSION::STRING + end + + module VERSION + MAJOR = 8 + MINOR = 1 + TINY = 0 + PRE = "alpha" + + STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") + end +end diff --git a/actioncable/lib/action_cable/helpers/action_cable_helper.rb b/actioncable/lib/action_cable/helpers/action_cable_helper.rb new file mode 100644 index 0000000000000..93d21a983cef8 --- /dev/null +++ b/actioncable/lib/action_cable/helpers/action_cable_helper.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Helpers + module ActionCableHelper + # Returns an "action-cable-url" meta tag with the value of the URL specified in + # your configuration. Ensure this is above your JavaScript tag: + # + # + # <%= action_cable_meta_tag %> + # <%= javascript_include_tag 'application', 'data-turbo-track' => 'reload' %> + # + # + # This is then used by Action Cable to determine the URL of your WebSocket + # server. Your JavaScript can then connect to the server without needing to + # specify the URL directly: + # + # import Cable from "@rails/actioncable" + # window.Cable = Cable + # window.App = {} + # App.cable = Cable.createConsumer() + # + # Make sure to specify the correct server location in each of your environment + # config files: + # + # config.action_cable.mount_path = "/cable123" + # <%= action_cable_meta_tag %> would render: + # => + # + # config.action_cable.url = "ws://actioncable.com" + # <%= action_cable_meta_tag %> would render: + # => + # + def action_cable_meta_tag + tag "meta", name: "action-cable-url", content: ( + ActionCable.server.config.url || + ActionCable.server.config.mount_path || + raise("No Action Cable URL configured -- please configure this at config.action_cable.url") + ) + end + end + end +end diff --git a/actioncable/lib/action_cable/remote_connections.rb b/actioncable/lib/action_cable/remote_connections.rb new file mode 100644 index 0000000000000..e167a1c5521c8 --- /dev/null +++ b/actioncable/lib/action_cable/remote_connections.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/core_ext/module/redefine_method" + +module ActionCable + # # Action Cable Remote Connections + # + # If you need to disconnect a given connection, you can go through the + # RemoteConnections. You can find the connections you're looking for by + # searching for the identifier declared on the connection. For example: + # + # module ApplicationCable + # class Connection < ActionCable::Connection::Base + # identified_by :current_user + # .... + # end + # end + # + # ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect + # + # This will disconnect all the connections established for `User.find(1)`, + # across all servers running on all machines, because it uses the internal + # channel that all of these servers are subscribed to. + # + # By default, server sends a "disconnect" message with "reconnect" flag set to + # true. You can override it by specifying the `reconnect` option: + # + # ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect(reconnect: false) + class RemoteConnections + attr_reader :server + + def initialize(server) + @server = server + end + + def where(identifier) + RemoteConnection.new(server, identifier) + end + + # # Action Cable Remote Connection + # + # Represents a single remote connection found via + # `ActionCable.server.remote_connections.where(*)`. Exists solely for the + # purpose of calling #disconnect on that connection. + class RemoteConnection + class InvalidIdentifiersError < StandardError; end + + include Connection::Identification, Connection::InternalChannel + + def initialize(server, ids) + @server = server + set_identifier_instance_vars(ids) + end + + # Uses the internal channel to disconnect the connection. + def disconnect(reconnect: true) + server.broadcast internal_channel, { type: "disconnect", reconnect: reconnect } + end + + # Returns all the identifiers that were applied to this connection. + redefine_method :identifiers do + server.connection_identifiers + end + + protected + attr_reader :server + + private + def set_identifier_instance_vars(ids) + raise InvalidIdentifiersError unless valid_identifiers?(ids) + ids.each { |k, v| instance_variable_set("@#{k}", v) } + end + + def valid_identifiers?(ids) + keys = ids.keys + identifiers.all? { |id| keys.include?(id) } + end + end + end +end diff --git a/actioncable/lib/action_cable/server/base.rb b/actioncable/lib/action_cable/server/base.rb new file mode 100644 index 0000000000000..cd150700e2329 --- /dev/null +++ b/actioncable/lib/action_cable/server/base.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "monitor" + +module ActionCable + module Server + # # Action Cable Server Base + # + # A singleton ActionCable::Server instance is available via ActionCable.server. + # It's used by the Rack process that starts the Action Cable server, but is also + # used by the user to reach the RemoteConnections object, which is used for + # finding and disconnecting connections across all servers. + # + # Also, this is the server instance used for broadcasting. See Broadcasting for + # more information. + class Base + include ActionCable::Server::Broadcasting + include ActionCable::Server::Connections + + cattr_accessor :config, instance_accessor: false, default: ActionCable::Server::Configuration.new + + attr_reader :config + + def self.logger; config.logger; end + delegate :logger, to: :config + + attr_reader :mutex + + def initialize(config: self.class.config) + @config = config + @mutex = Monitor.new + @remote_connections = @event_loop = @worker_pool = @pubsub = nil + end + + # Called by Rack to set up the server. + def call(env) + return config.health_check_application.call(env) if env["PATH_INFO"] == config.health_check_path + setup_heartbeat_timer + config.connection_class.call.new(self, env).process + end + + # Disconnect all the connections identified by `identifiers` on this server or + # any others via RemoteConnections. + def disconnect(identifiers) + remote_connections.where(identifiers).disconnect + end + + def restart + connections.each do |connection| + connection.close(reason: ActionCable::INTERNAL[:disconnect_reasons][:server_restart]) + end + + @mutex.synchronize do + # Shutdown the worker pool + @worker_pool.halt if @worker_pool + @worker_pool = nil + + # Shutdown the pub/sub adapter + @pubsub.shutdown if @pubsub + @pubsub = nil + end + end + + # Gateway to RemoteConnections. See that class for details. + def remote_connections + @remote_connections || @mutex.synchronize { @remote_connections ||= RemoteConnections.new(self) } + end + + def event_loop + @event_loop || @mutex.synchronize { @event_loop ||= ActionCable::Connection::StreamEventLoop.new } + end + + # The worker pool is where we run connection callbacks and channel actions. We + # do as little as possible on the server's main thread. The worker pool is an + # executor service that's backed by a pool of threads working from a task queue. + # The thread pool size maxes out at 4 worker threads by default. Tune the size + # yourself with `config.action_cable.worker_pool_size`. + # + # Using Active Record, Redis, etc within your channel actions means you'll get a + # separate connection from each thread in the worker pool. Plan your deployment + # accordingly: 5 servers each running 5 Puma workers each running an 8-thread + # worker pool means at least 200 database connections. + # + # Also, ensure that your database connection pool size is as least as large as + # your worker pool size. Otherwise, workers may oversubscribe the database + # connection pool and block while they wait for other workers to release their + # connections. Use a smaller worker pool or a larger database connection pool + # instead. + def worker_pool + @worker_pool || @mutex.synchronize { @worker_pool ||= ActionCable::Server::Worker.new(max_size: config.worker_pool_size) } + end + + # Adapter used for all streams/broadcasting. + def pubsub + @pubsub || @mutex.synchronize { @pubsub ||= config.pubsub_adapter.new(self) } + end + + # All of the identifiers applied to the connection class associated with this + # server. + def connection_identifiers + config.connection_class.call.identifiers + end + end + + ActiveSupport.run_load_hooks(:action_cable, Base.config) + end +end diff --git a/actioncable/lib/action_cable/server/broadcasting.rb b/actioncable/lib/action_cable/server/broadcasting.rb new file mode 100644 index 0000000000000..cedd0fb58fb0b --- /dev/null +++ b/actioncable/lib/action_cable/server/broadcasting.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Server + # # Action Cable Server Broadcasting + # + # Broadcasting is how other parts of your application can send messages to a + # channel's subscribers. As explained in Channel, most of the time, these + # broadcastings are streamed directly to the clients subscribed to the named + # broadcasting. Let's explain with a full-stack example: + # + # class WebNotificationsChannel < ApplicationCable::Channel + # def subscribed + # stream_from "web_notifications_#{current_user.id}" + # end + # end + # + # # Somewhere in your app this is called, perhaps from a NewCommentJob: + # ActionCable.server.broadcast \ + # "web_notifications_1", { title: "New things!", body: "All that's fit for print" } + # + # # Client-side JavaScript, which assumes you've already requested the right to send web notifications: + # App.cable.subscriptions.create("WebNotificationsChannel", { + # received: function(data) { + # new Notification(data['title'], { body: data['body'] }) + # } + # }) + module Broadcasting + # Broadcast a hash directly to a named `broadcasting`. This will later be JSON + # encoded. + def broadcast(broadcasting, message, coder: ActiveSupport::JSON) + broadcaster_for(broadcasting, coder: coder).broadcast(message) + end + + # Returns a broadcaster for a named `broadcasting` that can be reused. Useful + # when you have an object that may need multiple spots to transmit to a specific + # broadcasting over and over. + def broadcaster_for(broadcasting, coder: ActiveSupport::JSON) + Broadcaster.new(self, String(broadcasting), coder: coder) + end + + private + class Broadcaster + attr_reader :server, :broadcasting, :coder + + def initialize(server, broadcasting, coder:) + @server, @broadcasting, @coder = server, broadcasting, coder + end + + def broadcast(message) + server.logger.debug { "[ActionCable] Broadcasting to #{broadcasting}: #{message.inspect.truncate(300)}" } + + payload = { broadcasting: broadcasting, message: message, coder: coder } + ActiveSupport::Notifications.instrument("broadcast.action_cable", payload) do + encoded = coder ? coder.encode(message) : message + server.pubsub.broadcast broadcasting, encoded + end + end + end + end + end +end diff --git a/actioncable/lib/action_cable/server/configuration.rb b/actioncable/lib/action_cable/server/configuration.rb new file mode 100644 index 0000000000000..6210bfcf47b9d --- /dev/null +++ b/actioncable/lib/action_cable/server/configuration.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "rack" + +module ActionCable + module Server + # # Action Cable Server Configuration + # + # An instance of this configuration object is available via + # ActionCable.server.config, which allows you to tweak Action Cable + # configuration in a Rails config initializer. + class Configuration + attr_accessor :logger, :log_tags + attr_accessor :connection_class, :worker_pool_size + attr_accessor :disable_request_forgery_protection, :allowed_request_origins, :allow_same_origin_as_host, :filter_parameters + attr_accessor :cable, :url, :mount_path + attr_accessor :precompile_assets + attr_accessor :health_check_path, :health_check_application + + def initialize + @log_tags = [] + + @connection_class = -> { ActionCable::Connection::Base } + @worker_pool_size = 4 + + @disable_request_forgery_protection = false + @allow_same_origin_as_host = true + @filter_parameters = [] + + @health_check_application = ->(env) { + [200, { Rack::CONTENT_TYPE => "text/html", "date" => Time.now.httpdate }, []] + } + end + + # Returns constant of subscription adapter specified in config/cable.yml. If the + # adapter cannot be found, this will default to the Redis adapter. Also makes + # sure proper dependencies are required. + def pubsub_adapter + adapter = (cable.fetch("http://github.com/adapter") { "redis" }) + + # Require the adapter itself and give useful feedback about + # 1. Missing adapter gems and + # 2. Adapter gems' missing dependencies. + path_to_adapter = "action_cable/subscription_adapter/#{adapter}" + begin + require path_to_adapter + rescue LoadError => e + # We couldn't require the adapter itself. Raise an exception that points out + # config typos and missing gems. + if e.path == path_to_adapter + # We can assume that a non-builtin adapter was specified, so it's either + # misspelled or missing from Gemfile. + raise e.class, "Could not load the '#{adapter}' Action Cable pubsub adapter. Ensure that the adapter is spelled correctly in config/cable.yml and that you've added the necessary adapter gem to your Gemfile.", e.backtrace + + # Bubbled up from the adapter require. Prefix the exception message with some + # guidance about how to address it and reraise. + else + raise e.class, "Error loading the '#{adapter}' Action Cable pubsub adapter. Missing a gem it depends on? #{e.message}", e.backtrace + end + end + + adapter = adapter.camelize + adapter = "PostgreSQL" if adapter == "Postgresql" + "ActionCable::SubscriptionAdapter::#{adapter}".constantize + end + end + end +end diff --git a/actioncable/lib/action_cable/server/connections.rb b/actioncable/lib/action_cable/server/connections.rb new file mode 100644 index 0000000000000..e51933b177410 --- /dev/null +++ b/actioncable/lib/action_cable/server/connections.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Server + # # Action Cable Server Connections + # + # Collection class for all the connections that have been established on this + # specific server. Remember, usually you'll run many Action Cable servers, so + # you can't use this collection as a full list of all of the connections + # established against your application. Instead, use RemoteConnections for that. + module Connections # :nodoc: + BEAT_INTERVAL = 3 + + def connections + @connections ||= [] + end + + def add_connection(connection) + connections << connection + end + + def remove_connection(connection) + connections.delete connection + end + + # WebSocket connection implementations differ on when they'll mark a connection + # as stale. We basically never want a connection to go stale, as you then can't + # rely on being able to communicate with the connection. To solve this, a 3 + # second heartbeat runs on all connections. If the beat fails, we automatically + # disconnect. + def setup_heartbeat_timer + @heartbeat_timer ||= event_loop.timer(BEAT_INTERVAL) do + event_loop.post { connections.each(&:beat) } + end + end + + def open_connections_statistics + connections.map(&:statistics) + end + end + end +end diff --git a/actioncable/lib/action_cable/server/worker.rb b/actioncable/lib/action_cable/server/worker.rb new file mode 100644 index 0000000000000..535849a1515e7 --- /dev/null +++ b/actioncable/lib/action_cable/server/worker.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/callbacks" +require "active_support/core_ext/module/attribute_accessors_per_thread" +require "concurrent" + +module ActionCable + module Server + # Worker used by Server.send_async to do connection work in threads. + class Worker # :nodoc: + include ActiveSupport::Callbacks + + thread_mattr_accessor :connection + define_callbacks :work + include ActiveRecordConnectionManagement + + attr_reader :executor + + def initialize(max_size: 5) + @executor = Concurrent::ThreadPoolExecutor.new( + name: "ActionCable-server", + min_threads: 1, + max_threads: max_size, + max_queue: 0, + ) + end + + # Stop processing work: any work that has not already started running will be + # discarded from the queue + def halt + @executor.shutdown + end + + def stopping? + @executor.shuttingdown? + end + + def work(connection, &block) + self.connection = connection + + run_callbacks :work, &block + ensure + self.connection = nil + end + + def async_exec(receiver, *args, connection:, &block) + async_invoke receiver, :instance_exec, *args, connection: connection, &block + end + + def async_invoke(receiver, method, *args, connection: receiver, &block) + @executor.post do + invoke(receiver, method, *args, connection: connection, &block) + end + end + + def invoke(receiver, method, *args, connection:, &block) + work(connection) do + receiver.send method, *args, &block + rescue Exception => e + logger.error "There was an exception - #{e.class}(#{e.message})" + logger.error e.backtrace.join("\n") + + receiver.handle_exception if receiver.respond_to?(:handle_exception) + end + end + + private + def logger + ActionCable.server.logger + end + end + end +end diff --git a/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb b/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb new file mode 100644 index 0000000000000..2512c500f4265 --- /dev/null +++ b/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module Server + class Worker + module ActiveRecordConnectionManagement + extend ActiveSupport::Concern + + included do + if defined?(ActiveRecord::Base) + set_callback :work, :around, :with_database_connections + end + end + + def with_database_connections(&block) + connection.logger.tag(ActiveRecord::Base.logger, &block) + end + end + end + end +end diff --git a/actioncable/lib/action_cable/subscription_adapter/async.rb b/actioncable/lib/action_cable/subscription_adapter/async.rb new file mode 100644 index 0000000000000..f78edb9ae7c35 --- /dev/null +++ b/actioncable/lib/action_cable/subscription_adapter/async.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module SubscriptionAdapter + class Async < Inline # :nodoc: + private + def new_subscriber_map + AsyncSubscriberMap.new(server.event_loop) + end + + class AsyncSubscriberMap < SubscriberMap + def initialize(event_loop) + @event_loop = event_loop + super() + end + + def add_subscriber(*) + @event_loop.post { super } + end + + def invoke_callback(*) + @event_loop.post { super } + end + end + end + end +end diff --git a/actioncable/lib/action_cable/subscription_adapter/base.rb b/actioncable/lib/action_cable/subscription_adapter/base.rb new file mode 100644 index 0000000000000..2df2667b2336c --- /dev/null +++ b/actioncable/lib/action_cable/subscription_adapter/base.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module SubscriptionAdapter + class Base + attr_reader :logger, :server + + def initialize(server) + @server = server + @logger = @server.logger + end + + def broadcast(channel, payload) + raise NotImplementedError + end + + def subscribe(channel, message_callback, success_callback = nil) + raise NotImplementedError + end + + def unsubscribe(channel, message_callback) + raise NotImplementedError + end + + def shutdown + raise NotImplementedError + end + + def identifier + @server.config.cable[:id] = "ActionCable-PID-#{$$}" unless @server.config.cable.key?(:id) + @server.config.cable[:id] + end + end + end +end diff --git a/actioncable/lib/action_cable/subscription_adapter/channel_prefix.rb b/actioncable/lib/action_cable/subscription_adapter/channel_prefix.rb new file mode 100644 index 0000000000000..2c3e88f307ed2 --- /dev/null +++ b/actioncable/lib/action_cable/subscription_adapter/channel_prefix.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module SubscriptionAdapter + module ChannelPrefix # :nodoc: + def broadcast(channel, payload) + channel = channel_with_prefix(channel) + super + end + + def subscribe(channel, callback, success_callback = nil) + channel = channel_with_prefix(channel) + super + end + + def unsubscribe(channel, callback) + channel = channel_with_prefix(channel) + super + end + + private + # Returns the channel name, including channel_prefix specified in cable.yml + def channel_with_prefix(channel) + [@server.config.cable[:channel_prefix], channel].compact.join(":") + end + end + end +end diff --git a/actioncable/lib/action_cable/subscription_adapter/inline.rb b/actioncable/lib/action_cable/subscription_adapter/inline.rb new file mode 100644 index 0000000000000..88559fb39c64e --- /dev/null +++ b/actioncable/lib/action_cable/subscription_adapter/inline.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module SubscriptionAdapter + class Inline < Base # :nodoc: + def initialize(*) + super + @subscriber_map = nil + end + + def broadcast(channel, payload) + subscriber_map.broadcast(channel, payload) + end + + def subscribe(channel, callback, success_callback = nil) + subscriber_map.add_subscriber(channel, callback, success_callback) + end + + def unsubscribe(channel, callback) + subscriber_map.remove_subscriber(channel, callback) + end + + def shutdown + # nothing to do + end + + private + def subscriber_map + @subscriber_map || @server.mutex.synchronize { @subscriber_map ||= new_subscriber_map } + end + + def new_subscriber_map + SubscriberMap.new + end + end + end +end diff --git a/actioncable/lib/action_cable/subscription_adapter/postgresql.rb b/actioncable/lib/action_cable/subscription_adapter/postgresql.rb new file mode 100644 index 0000000000000..32c63bbebf50a --- /dev/null +++ b/actioncable/lib/action_cable/subscription_adapter/postgresql.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +# :markup: markdown + +gem "pg", "~> 1.1" +require "pg" +require "openssl" + +module ActionCable + module SubscriptionAdapter + class PostgreSQL < Base # :nodoc: + prepend ChannelPrefix + + def initialize(*) + super + @listener = nil + end + + def broadcast(channel, payload) + with_broadcast_connection do |pg_conn| + pg_conn.exec("NOTIFY #{pg_conn.escape_identifier(channel_identifier(channel))}, '#{pg_conn.escape_string(payload)}'") + end + end + + def subscribe(channel, callback, success_callback = nil) + listener.add_subscriber(channel_identifier(channel), callback, success_callback) + end + + def unsubscribe(channel, callback) + listener.remove_subscriber(channel_identifier(channel), callback) + end + + def shutdown + listener.shutdown + end + + def with_subscriptions_connection(&block) # :nodoc: + # Action Cable is taking ownership over this database connection, and will + # perform the necessary cleanup tasks. + # We purposedly avoid #checkout to not end up with a pinned connection + ar_conn = ActiveRecord::Base.connection_pool.new_connection + pg_conn = ar_conn.raw_connection + + verify!(pg_conn) + pg_conn.exec("SET application_name = #{pg_conn.escape_identifier(identifier)}") + yield pg_conn + ensure + ar_conn&.disconnect! + end + + def with_broadcast_connection(&block) # :nodoc: + ActiveRecord::Base.connection_pool.with_connection do |ar_conn| + pg_conn = ar_conn.raw_connection + verify!(pg_conn) + yield pg_conn + end + end + + private + def channel_identifier(channel) + channel.size > 63 ? OpenSSL::Digest::SHA1.hexdigest(channel) : channel + end + + def listener + @listener || @server.mutex.synchronize { @listener ||= Listener.new(self, @server.event_loop) } + end + + def verify!(pg_conn) + unless pg_conn.is_a?(PG::Connection) + raise "The Active Record database must be PostgreSQL in order to use the PostgreSQL Action Cable storage adapter" + end + end + + class Listener < SubscriberMap + def initialize(adapter, event_loop) + super() + + @adapter = adapter + @event_loop = event_loop + @queue = Queue.new + + @thread = Thread.new do + Thread.current.abort_on_exception = true + listen + end + end + + def listen + @adapter.with_subscriptions_connection do |pg_conn| + catch :shutdown do + loop do + until @queue.empty? + action, channel, callback = @queue.pop(true) + + case action + when :listen + pg_conn.exec("LISTEN #{pg_conn.escape_identifier channel}") + @event_loop.post(&callback) if callback + when :unlisten + pg_conn.exec("UNLISTEN #{pg_conn.escape_identifier channel}") + when :shutdown + throw :shutdown + end + end + + pg_conn.wait_for_notify(1) do |chan, pid, message| + broadcast(chan, message) + end + end + end + end + end + + def shutdown + @queue.push([:shutdown]) + Thread.pass while @thread.alive? + end + + def add_channel(channel, on_success) + @queue.push([:listen, channel, on_success]) + end + + def remove_channel(channel) + @queue.push([:unlisten, channel]) + end + + def invoke_callback(*) + @event_loop.post { super } + end + end + end + end +end diff --git a/actioncable/lib/action_cable/subscription_adapter/redis.rb b/actioncable/lib/action_cable/subscription_adapter/redis.rb new file mode 100644 index 0000000000000..da58e8652ce8c --- /dev/null +++ b/actioncable/lib/action_cable/subscription_adapter/redis.rb @@ -0,0 +1,256 @@ +# frozen_string_literal: true + +# :markup: markdown + +gem "redis", ">= 4", "< 6" +require "redis" + +require "active_support/core_ext/hash/except" + +module ActionCable + module SubscriptionAdapter + class Redis < Base # :nodoc: + prepend ChannelPrefix + + # Overwrite this factory method for Redis connections if you want to use a + # different Redis library than the redis gem. This is needed, for example, when + # using Makara proxies for distributed Redis. + cattr_accessor :redis_connector, default: ->(config) do + ::Redis.new(config.except(:adapter, :channel_prefix)) + end + + def initialize(*) + super + @listener = nil + @redis_connection_for_broadcasts = nil + end + + def broadcast(channel, payload) + redis_connection_for_broadcasts.publish(channel, payload) + end + + def subscribe(channel, callback, success_callback = nil) + listener.add_subscriber(channel, callback, success_callback) + end + + def unsubscribe(channel, callback) + listener.remove_subscriber(channel, callback) + end + + def shutdown + @listener.shutdown if @listener + end + + def redis_connection_for_subscriptions + redis_connection + end + + private + def listener + @listener || @server.mutex.synchronize { @listener ||= Listener.new(self, config_options, @server.event_loop) } + end + + def redis_connection_for_broadcasts + @redis_connection_for_broadcasts || @server.mutex.synchronize do + @redis_connection_for_broadcasts ||= redis_connection + end + end + + def redis_connection + self.class.redis_connector.call(config_options) + end + + def config_options + @config_options ||= @server.config.cable.deep_symbolize_keys.merge(id: identifier) + end + + class Listener < SubscriberMap + def initialize(adapter, config_options, event_loop) + super() + + @adapter = adapter + @event_loop = event_loop + + @subscribe_callbacks = Hash.new { |h, k| h[k] = [] } + @subscription_lock = Mutex.new + + @reconnect_attempt = 0 + # Use the same config as used by Redis conn + @reconnect_attempts = config_options.fetch(:reconnect_attempts, 1) + @reconnect_attempts = Array.new(@reconnect_attempts, 0) if @reconnect_attempts.is_a?(Integer) + + @subscribed_client = nil + + @when_connected = [] + + @thread = nil + end + + def listen(conn) + conn.without_reconnect do + original_client = extract_subscribed_client(conn) + + conn.subscribe("_action_cable_internal") do |on| + on.subscribe do |chan, count| + @subscription_lock.synchronize do + if count == 1 + @reconnect_attempt = 0 + @subscribed_client = original_client + + until @when_connected.empty? + @when_connected.shift.call + end + end + + if callbacks = @subscribe_callbacks[chan] + next_callback = callbacks.shift + @event_loop.post(&next_callback) if next_callback + @subscribe_callbacks.delete(chan) if callbacks.empty? + end + end + end + + on.message do |chan, message| + broadcast(chan, message) + end + + on.unsubscribe do |chan, count| + if count == 0 + @subscription_lock.synchronize do + @subscribed_client = nil + end + end + end + end + end + end + + def shutdown + @subscription_lock.synchronize do + return if @thread.nil? + + when_connected do + @subscribed_client.unsubscribe + @subscribed_client = nil + end + end + + Thread.pass while @thread.alive? + end + + def add_channel(channel, on_success) + @subscription_lock.synchronize do + ensure_listener_running + @subscribe_callbacks[channel] << on_success + when_connected { @subscribed_client.subscribe(channel) } + end + end + + def remove_channel(channel) + @subscription_lock.synchronize do + when_connected { @subscribed_client.unsubscribe(channel) } + end + end + + def invoke_callback(*) + @event_loop.post { super } + end + + private + def ensure_listener_running + @thread ||= Thread.new do + Thread.current.abort_on_exception = true + + begin + conn = @adapter.redis_connection_for_subscriptions + listen conn + rescue ConnectionError + reset + if retry_connecting? + when_connected { resubscribe } + retry + end + end + end + end + + def when_connected(&block) + if @subscribed_client + block.call + else + @when_connected << block + end + end + + def retry_connecting? + @reconnect_attempt += 1 + + return false if @reconnect_attempt > @reconnect_attempts.size + + sleep_t = @reconnect_attempts[@reconnect_attempt - 1] + + sleep(sleep_t) if sleep_t > 0 + + true + end + + def resubscribe + channels = @sync.synchronize do + @subscribers.keys + end + @subscribed_client.subscribe(*channels) unless channels.empty? + end + + def reset + @subscription_lock.synchronize do + @subscribed_client = nil + @subscribe_callbacks.clear + @when_connected.clear + end + end + + if ::Redis::VERSION < "5" + ConnectionError = ::Redis::BaseConnectionError + + class SubscribedClient + def initialize(raw_client) + @raw_client = raw_client + end + + def subscribe(*channel) + send_command("subscribe", *channel) + end + + def unsubscribe(*channel) + send_command("unsubscribe", *channel) + end + + private + def send_command(*command) + @raw_client.write(command) + + very_raw_connection = + @raw_client.connection.instance_variable_defined?(:@connection) && + @raw_client.connection.instance_variable_get(:@connection) + + if very_raw_connection && very_raw_connection.respond_to?(:flush) + very_raw_connection.flush + end + nil + end + end + + def extract_subscribed_client(conn) + SubscribedClient.new(conn._client) + end + else + ConnectionError = RedisClient::ConnectionError + + def extract_subscribed_client(conn) + conn + end + end + end + end + end +end diff --git a/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb b/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb new file mode 100644 index 0000000000000..d541681d3d473 --- /dev/null +++ b/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module SubscriptionAdapter + class SubscriberMap + def initialize + @subscribers = Hash.new { |h, k| h[k] = [] } + @sync = Mutex.new + end + + def add_subscriber(channel, subscriber, on_success) + @sync.synchronize do + new_channel = !@subscribers.key?(channel) + + @subscribers[channel] << subscriber + + if new_channel + add_channel channel, on_success + elsif on_success + on_success.call + end + end + end + + def remove_subscriber(channel, subscriber) + @sync.synchronize do + @subscribers[channel].delete(subscriber) + + if @subscribers[channel].empty? + @subscribers.delete channel + remove_channel channel + end + end + end + + def broadcast(channel, message) + list = @sync.synchronize do + return if !@subscribers.key?(channel) + @subscribers[channel].dup + end + + list.each do |subscriber| + invoke_callback(subscriber, message) + end + end + + def add_channel(channel, on_success) + on_success.call if on_success + end + + def remove_channel(channel) + end + + def invoke_callback(callback, message) + callback.call message + end + end + end +end diff --git a/actioncable/lib/action_cable/subscription_adapter/test.rb b/actioncable/lib/action_cable/subscription_adapter/test.rb new file mode 100644 index 0000000000000..d09018aabbab5 --- /dev/null +++ b/actioncable/lib/action_cable/subscription_adapter/test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + module SubscriptionAdapter + # ## Test adapter for Action Cable + # + # The test adapter should be used only in testing. Along with + # ActionCable::TestHelper it makes a great tool to test your Rails application. + # + # To use the test adapter set `adapter` value to `test` in your + # `config/cable.yml` file. + # + # NOTE: `Test` adapter extends the `ActionCable::SubscriptionAdapter::Async` + # adapter, so it could be used in system tests too. + class Test < Async + def broadcast(channel, payload) + broadcasts(channel) << payload + super + end + + def broadcasts(channel) + channels_data[channel] ||= [] + end + + def clear_messages(channel) + channels_data[channel] = [] + end + + def clear + @channels_data = nil + end + + private + def channels_data + @channels_data ||= {} + end + end + end +end diff --git a/actioncable/lib/action_cable/test_case.rb b/actioncable/lib/action_cable/test_case.rb new file mode 100644 index 0000000000000..b56f2ead6c306 --- /dev/null +++ b/actioncable/lib/action_cable/test_case.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# :markup: markdown + +require "active_support/test_case" + +module ActionCable + class TestCase < ActiveSupport::TestCase + include ActionCable::TestHelper + + ActiveSupport.run_load_hooks(:action_cable_test_case, self) + end +end diff --git a/actioncable/lib/action_cable/test_helper.rb b/actioncable/lib/action_cable/test_helper.rb new file mode 100644 index 0000000000000..682d8fc039843 --- /dev/null +++ b/actioncable/lib/action_cable/test_helper.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +# :markup: markdown + +module ActionCable + # Provides helper methods for testing Action Cable broadcasting + module TestHelper + def before_setup # :nodoc: + server = ActionCable.server + test_adapter = ActionCable::SubscriptionAdapter::Test.new(server) + + @old_pubsub_adapter = server.pubsub + + server.instance_variable_set(:@pubsub, test_adapter) + super + end + + def after_teardown # :nodoc: + super + ActionCable.server.instance_variable_set(:@pubsub, @old_pubsub_adapter) + end + + # Asserts that the number of broadcasted messages to the stream matches the + # given number. + # + # def test_broadcasts + # assert_broadcasts 'messages', 0 + # ActionCable.server.broadcast 'messages', { text: 'hello' } + # assert_broadcasts 'messages', 1 + # ActionCable.server.broadcast 'messages', { text: 'world' } + # assert_broadcasts 'messages', 2 + # end + # + # If a block is passed, that block should cause the specified number of messages + # to be broadcasted. + # + # def test_broadcasts_again + # assert_broadcasts('messages', 1) do + # ActionCable.server.broadcast 'messages', { text: 'hello' } + # end + # + # assert_broadcasts('messages', 2) do + # ActionCable.server.broadcast 'messages', { text: 'hi' } + # ActionCable.server.broadcast 'messages', { text: 'how are you?' } + # end + # end + # + def assert_broadcasts(stream, number, &block) + if block_given? + new_messages = new_broadcasts_from(broadcasts(stream), stream, "assert_broadcasts", &block) + + actual_count = new_messages.size + assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent" + else + actual_count = broadcasts(stream).size + assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent" + end + end + + # Asserts that no messages have been sent to the stream. + # + # def test_no_broadcasts + # assert_no_broadcasts 'messages' + # ActionCable.server.broadcast 'messages', { text: 'hi' } + # assert_broadcasts 'messages', 1 + # end + # + # If a block is passed, that block should not cause any message to be sent. + # + # def test_broadcasts_again + # assert_no_broadcasts 'messages' do + # # No job messages should be sent from this block + # end + # end + # + # Note: This assertion is simply a shortcut for: + # + # assert_broadcasts 'messages', 0, &block + # + def assert_no_broadcasts(stream, &block) + assert_broadcasts stream, 0, &block + end + + # Returns the messages that are broadcasted in the block. + # + # def test_broadcasts + # messages = capture_broadcasts('messages') do + # ActionCable.server.broadcast 'messages', { text: 'hi' } + # ActionCable.server.broadcast 'messages', { text: 'how are you?' } + # end + # assert_equal 2, messages.length + # assert_equal({ text: 'hi' }, messages.first) + # assert_equal({ text: 'how are you?' }, messages.last) + # end + # + def capture_broadcasts(stream, &block) + new_broadcasts_from(broadcasts(stream), stream, "capture_broadcasts", &block).map { |m| ActiveSupport::JSON.decode(m) } + end + + # Asserts that the specified message has been sent to the stream. + # + # def test_assert_transmitted_message + # ActionCable.server.broadcast 'messages', text: 'hello' + # assert_broadcast_on('messages', text: 'hello') + # end + # + # If a block is passed, that block should cause a message with the specified + # data to be sent. + # + # def test_assert_broadcast_on_again + # assert_broadcast_on('messages', text: 'hello') do + # ActionCable.server.broadcast 'messages', text: 'hello' + # end + # end + # + def assert_broadcast_on(stream, data, &block) + # Encode to JSON and back–we want to use this value to compare with decoded + # JSON. Comparing JSON strings doesn't work due to the order if the keys. + serialized_msg = + ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(data)) + + new_messages = broadcasts(stream) + if block_given? + new_messages = new_broadcasts_from(new_messages, stream, "assert_broadcast_on", &block) + end + + message = new_messages.find { |msg| ActiveSupport::JSON.decode(msg) == serialized_msg } + + error_message = "No messages sent with #{data} to #{stream}" + + if new_messages.any? + error_message = new_messages.inject("#{error_message}\nMessage(s) found:\n") do |error_message, new_message| + error_message + "#{ActiveSupport::JSON.decode(new_message)}\n" + end + else + error_message = "#{error_message}\nNo message found for #{stream}" + end + + assert message, error_message + end + + def pubsub_adapter # :nodoc: + ActionCable.server.pubsub + end + + delegate :broadcasts, :clear_messages, to: :pubsub_adapter + + private + def new_broadcasts_from(current_messages, stream, assertion, &block) + old_messages = current_messages + clear_messages(stream) + + _assert_nothing_raised_or_warn(assertion, &block) + new_messages = broadcasts(stream) + clear_messages(stream) + + # Restore all sent messages + (old_messages + new_messages).each { |m| pubsub_adapter.broadcast(stream, m) } + + new_messages + end + end +end diff --git a/actioncable/lib/action_cable/version.rb b/actioncable/lib/action_cable/version.rb new file mode 100644 index 0000000000000..14423f9a6a1b6 --- /dev/null +++ b/actioncable/lib/action_cable/version.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# :markup: markdown + +require_relative "gem_version" + +module ActionCable + # Returns the currently loaded version of Action Cable as a `Gem::Version`. + def self.version + gem_version + end +end diff --git a/actioncable/lib/rails/generators/channel/USAGE b/actioncable/lib/rails/generators/channel/USAGE new file mode 100644 index 0000000000000..13190920227e3 --- /dev/null +++ b/actioncable/lib/rails/generators/channel/USAGE @@ -0,0 +1,19 @@ +Description: + Generates a new cable channel for the server (in Ruby) and client (in JavaScript). + Pass the channel name, either CamelCased or under_scored, and an optional list of channel actions as arguments. + +Examples: + `bin/rails generate channel notification` + + creates a notification channel class, test and JavaScript asset: + Channel: app/channels/notification_channel.rb + Test: test/channels/notification_channel_test.rb + Assets: $JAVASCRIPT_PATH/channels/notification_channel.js + + `bin/rails generate channel chat speak` + + creates a chat channel with a speak action. + + `bin/rails generate channel comments --no-assets` + + creates a comments channel without JavaScript assets. diff --git a/actioncable/lib/rails/generators/channel/channel_generator.rb b/actioncable/lib/rails/generators/channel/channel_generator.rb new file mode 100644 index 0000000000000..72a6a3821ebe8 --- /dev/null +++ b/actioncable/lib/rails/generators/channel/channel_generator.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +# :markup: markdown + +module Rails + module Generators + class ChannelGenerator < NamedBase + source_root File.expand_path("templates", __dir__) + + argument :actions, type: :array, default: [], banner: "method method" + + class_option :assets, type: :boolean + + check_class_collision suffix: "Channel" + + hook_for :test_framework + + def create_channel_files + create_shared_channel_files + create_channel_file + + if using_javascript? + if first_setup_required? + create_shared_channel_javascript_files + import_channels_in_javascript_entrypoint + + if using_importmap? + pin_javascript_dependencies + elsif using_js_runtime? + install_javascript_dependencies + end + end + + create_channel_javascript_file + import_channel_in_javascript_entrypoint + end + end + + private + def create_shared_channel_files + return if behavior != :invoke + + copy_file "#{__dir__}/templates/application_cable/channel.rb", + "app/channels/application_cable/channel.rb" + copy_file "#{__dir__}/templates/application_cable/connection.rb", + "app/channels/application_cable/connection.rb" + end + + def create_channel_file + template "channel.rb", + File.join("app/channels", class_path, "#{file_name}_channel.rb") + end + + def create_shared_channel_javascript_files + template "javascript/index.js", "app/javascript/channels/index.js" + template "javascript/consumer.js", "app/javascript/channels/consumer.js" + end + + def create_channel_javascript_file + channel_js_path = File.join("app/javascript/channels", class_path, "#{file_name}_channel") + js_template "javascript/channel", channel_js_path + gsub_file "#{channel_js_path}.js", /\.\/consumer/, "channels/consumer" if using_importmap? + end + + def import_channels_in_javascript_entrypoint + append_to_file "app/javascript/application.js", + using_importmap? ? %(import "channels"\n) : %(import "./channels"\n) + end + + def import_channel_in_javascript_entrypoint + append_to_file "app/javascript/channels/index.js", + using_importmap? ? %(import "channels/#{file_name}_channel"\n) : %(import "./#{file_name}_channel"\n) + end + + def install_javascript_dependencies + say "Installing JavaScript dependencies", :green + if using_bun? + run "bun add @rails/actioncable" + elsif using_node? + run "yarn add @rails/actioncable" + end + end + + def pin_javascript_dependencies + append_to_file "config/importmap.rb", <<-RUBY +pin "@rails/actioncable", to: "actioncable.esm.js" +pin_all_from "app/javascript/channels", under: "channels" + RUBY + end + + def file_name + @_file_name ||= super.sub(/_channel\z/i, "") + end + + def first_setup_required? + !root.join("app/javascript/channels/index.js").exist? + end + + def using_javascript? + @using_javascript ||= options[:assets] && root.join("app/javascript").exist? + end + + def using_js_runtime? + @using_js_runtime ||= root.join("package.json").exist? + end + + def using_bun? + # Cannot assume Bun lockfile has been generated yet so we look for a file known to + # be generated by the jsbundling-rails gem + @using_bun ||= using_js_runtime? && root.join("bun.config.js").exist? + end + + def using_node? + # Bun is the only runtime that _isn't_ node. + @using_node ||= using_js_runtime? && !root.join("bun.config.js").exist? + end + + def using_importmap? + @using_importmap ||= root.join("config/importmap.rb").exist? + end + + def root + @root ||= Pathname(destination_root) + end + end + end +end diff --git a/actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb.tt b/actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb.tt new file mode 100644 index 0000000000000..d67269728300b --- /dev/null +++ b/actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb.tt @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb.tt b/actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb.tt new file mode 100644 index 0000000000000..0ff5442f476f9 --- /dev/null +++ b/actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb.tt @@ -0,0 +1,4 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/actioncable/lib/rails/generators/channel/templates/channel.rb.tt b/actioncable/lib/rails/generators/channel/templates/channel.rb.tt new file mode 100644 index 0000000000000..4bcfb2be4dc19 --- /dev/null +++ b/actioncable/lib/rails/generators/channel/templates/channel.rb.tt @@ -0,0 +1,16 @@ +<% module_namespacing do -%> +class <%= class_name %>Channel < ApplicationCable::Channel + def subscribed + # stream_from "some_channel" + end + + def unsubscribed + # Any cleanup needed when channel is unsubscribed + end +<% actions.each do |action| -%> + + def <%= action %> + end +<% end -%> +end +<% end -%> diff --git a/actioncable/lib/rails/generators/channel/templates/javascript/channel.js.tt b/actioncable/lib/rails/generators/channel/templates/javascript/channel.js.tt new file mode 100644 index 0000000000000..ddf6b2d79ba0a --- /dev/null +++ b/actioncable/lib/rails/generators/channel/templates/javascript/channel.js.tt @@ -0,0 +1,20 @@ +import consumer from "./consumer" + +consumer.subscriptions.create("<%= class_name %>Channel", { + connected() { + // Called when the subscription is ready for use on the server + }, + + disconnected() { + // Called when the subscription has been terminated by the server + }, + + received(data) { + // Called when there's incoming data on the websocket for this channel + }<%= actions.any? ? ",\n" : '' %> +<% actions.each do |action| -%> + <%=action %>: function() { + return this.perform('<%= action %>'); + }<%= action == actions[-1] ? '' : ",\n" %> +<% end -%> +}); diff --git a/actioncable/lib/rails/generators/channel/templates/javascript/consumer.js.tt b/actioncable/lib/rails/generators/channel/templates/javascript/consumer.js.tt new file mode 100644 index 0000000000000..8ec3aad3ae962 --- /dev/null +++ b/actioncable/lib/rails/generators/channel/templates/javascript/consumer.js.tt @@ -0,0 +1,6 @@ +// Action Cable provides the framework to deal with WebSockets in Rails. +// You can generate new channels where WebSocket features live using the `bin/rails generate channel` command. + +import { createConsumer } from "@rails/actioncable" + +export default createConsumer() diff --git a/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt b/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt new file mode 100644 index 0000000000000..08dc8af2a03bd --- /dev/null +++ b/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt @@ -0,0 +1 @@ +// Import all the channels to be used by Action Cable diff --git a/actioncable/lib/rails/generators/test_unit/channel_generator.rb b/actioncable/lib/rails/generators/test_unit/channel_generator.rb new file mode 100644 index 0000000000000..6054a3be77aff --- /dev/null +++ b/actioncable/lib/rails/generators/test_unit/channel_generator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# :markup: markdown + +module TestUnit + module Generators + class ChannelGenerator < ::Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + check_class_collision suffix: "ChannelTest" + + def create_test_files + template "channel_test.rb", File.join("test/channels", class_path, "#{file_name}_channel_test.rb") + end + + private + def file_name # :doc: + @_file_name ||= super.sub(/_channel\z/i, "") + end + end + end +end diff --git a/actioncable/lib/rails/generators/test_unit/templates/channel_test.rb.tt b/actioncable/lib/rails/generators/test_unit/templates/channel_test.rb.tt new file mode 100644 index 0000000000000..7307654611402 --- /dev/null +++ b/actioncable/lib/rails/generators/test_unit/templates/channel_test.rb.tt @@ -0,0 +1,8 @@ +require "test_helper" + +class <%= class_name %>ChannelTest < ActionCable::Channel::TestCase + # test "subscribes" do + # subscribe + # assert subscription.confirmed? + # end +end diff --git a/actioncable/package.json b/actioncable/package.json new file mode 100644 index 0000000000000..0a6c0d20b2830 --- /dev/null +++ b/actioncable/package.json @@ -0,0 +1,48 @@ +{ + "name": "@rails/actioncable", + "version": "8.1.0-alpha", + "description": "WebSocket framework for Ruby on Rails.", + "module": "app/assets/javascripts/actioncable.esm.js", + "main": "app/assets/javascripts/actioncable.js", + "files": [ + "app/assets/javascripts/*.js", + "src/*.js" + ], + "repository": { + "type": "git", + "url": "rails/rails" + }, + "keywords": [ + "websockets", + "actioncable", + "rails" + ], + "author": "David Heinemeier Hansson ", + "license": "MIT", + "bugs": { + "url": "https://github.com/rails/rails/issues" + }, + "homepage": "https://rubyonrails.org/", + "devDependencies": { + "@rollup/plugin-commonjs": "^19.0.1", + "@rollup/plugin-node-resolve": "^11.0.1", + "eslint": "^8.40.0", + "eslint-plugin-import": "^2.29.0", + "karma": "^6.4.2", + "karma-chrome-launcher": "^2.2.0", + "karma-qunit": "^2.1.0", + "karma-sauce-launcher": "^1.2.0", + "mock-socket": "^2.0.0", + "qunit": "^2.8.0", + "rollup": "^2.35.1", + "rollup-plugin-terser": "^7.0.2" + }, + "scripts": { + "prebuild": "yarn lint && bundle exec rake assets:codegen", + "build": "rollup --config rollup.config.js", + "lint": "eslint app/javascript", + "prepublishOnly": "rm -rf src && cp -R app/javascript/action_cable src", + "pretest": "bundle exec rake assets:codegen && rollup --config rollup.config.test.js", + "test": "karma start" + } +} diff --git a/actioncable/rollup.config.js b/actioncable/rollup.config.js new file mode 100644 index 0000000000000..0ae1181be17dd --- /dev/null +++ b/actioncable/rollup.config.js @@ -0,0 +1,46 @@ +import resolve from "@rollup/plugin-node-resolve" +import { terser } from "rollup-plugin-terser" + +const terserOptions = { + mangle: false, + compress: false, + format: { + beautify: true, + indent_level: 2 + } +} + +export default [ + { + input: "app/javascript/action_cable/index.js", + output: [ + { + file: "app/assets/javascripts/actioncable.js", + format: "umd", + name: "ActionCable" + }, + + { + file: "app/assets/javascripts/actioncable.esm.js", + format: "es" + } + ], + plugins: [ + resolve(), + terser(terserOptions) + ] + }, + + { + input: "app/javascript/action_cable/index_with_name_deprecation.js", + output: { + file: "app/assets/javascripts/action_cable.js", + format: "umd", + name: "ActionCable" + }, + plugins: [ + resolve(), + terser(terserOptions) + ] + }, +] diff --git a/actioncable/rollup.config.test.js b/actioncable/rollup.config.test.js new file mode 100644 index 0000000000000..06ddc8871ce52 --- /dev/null +++ b/actioncable/rollup.config.test.js @@ -0,0 +1,16 @@ +import commonjs from "@rollup/plugin-commonjs" +import resolve from "@rollup/plugin-node-resolve" + +export default { + input: "test/javascript/src/test.js", + + output: { + file: "test/javascript/compiled/test.js", + format: "iife" + }, + + plugins: [ + resolve(), + commonjs() + ] +} diff --git a/actioncable/test/channel/base_test.rb b/actioncable/test/channel/base_test.rb new file mode 100644 index 0000000000000..4394338d38a25 --- /dev/null +++ b/actioncable/test/channel/base_test.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/mock" +require "stubs/test_connection" +require "stubs/room" + +class ActionCable::Channel::BaseTest < ActionCable::TestCase + class ActionCable::Channel::Base + def kick + @last_action = [ :kick ] + end + + def topic + end + end + + class BasicChannel < ActionCable::Channel::Base + def chatters + @last_action = [ :chatters ] + end + end + + class ChatChannel < BasicChannel + attr_reader :room, :last_action + after_subscribe :toggle_subscribed + after_unsubscribe :toggle_subscribed + + class SomeCustomError < StandardError; end + rescue_from SomeCustomError, with: :error_handler + + def initialize(*) + @subscribed = false + super + end + + def subscribed + @room = Room.new params[:id] + @actions = [] + end + + def unsubscribed + @room = nil + end + + def toggle_subscribed + @subscribed = !@subscribed + end + + def leave + @last_action = [ :leave ] + end + + def speak(data) + @last_action = [ :speak, data ] + end + + def topic(data) + @last_action = [ :topic, data ] + end + + def subscribed? + @subscribed + end + + def get_latest + transmit({ data: "latest" }) + end + + def receive + @last_action = [ :receive ] + end + + def error_action + raise SomeCustomError + end + + private + def rm_rf + @last_action = [ :rm_rf ] + end + + def error_handler + @last_action = [ :error_action ] + end + end + + setup do + @user = User.new "lifo" + @connection = TestConnection.new(@user) + @channel = ChatChannel.new @connection, "{id: 1}", id: 1 + end + + test "should subscribe to a channel" do + @channel.subscribe_to_channel + assert_equal 1, @channel.room.id + end + + test "on subscribe callbacks" do + @channel.subscribe_to_channel + assert @channel.subscribed + end + + test "channel params" do + assert_equal({ id: 1 }, @channel.params) + end + + test "does not log filtered parameters" do + @connection.server.config.filter_parameters << :password + data = { password: "password", foo: "foo" } + + assert_logged({ password: "[FILTERED]" }.inspect[1..-2]) do + @channel.perform_action data + end + end + + test "unsubscribing from a channel" do + @channel.subscribe_to_channel + + assert @channel.room + assert_predicate @channel, :subscribed? + + @channel.unsubscribe_from_channel + + assert_not @channel.room + assert_not_predicate @channel, :subscribed? + end + + test "connection identifiers" do + assert_equal @user.name, @channel.current_user.name + end + + test "callable action without any argument" do + @channel.perform_action "action" => :leave + assert_equal [ :leave ], @channel.last_action + end + + test "callable action with arguments" do + data = { "action" => :speak, "content" => "Hello World" } + + @channel.perform_action data + assert_equal [ :speak, data ], @channel.last_action + end + + test "should not dispatch a private method" do + @channel.perform_action "action" => :rm_rf + assert_nil @channel.last_action + end + + test "should not dispatch a public method defined on Base" do + @channel.perform_action "action" => :kick + assert_nil @channel.last_action + end + + test "should dispatch a public method defined on Base and redefined on channel" do + data = { "action" => :topic, "content" => "This is Sparta!" } + + @channel.perform_action data + assert_equal [ :topic, data ], @channel.last_action + end + + test "should dispatch calling a public method defined in an ancestor" do + @channel.perform_action "action" => :chatters + assert_equal [ :chatters ], @channel.last_action + end + + test "should dispatch receive action when perform_action is called with empty action" do + data = { "content" => "hello" } + @channel.perform_action data + assert_equal [ :receive ], @channel.last_action + end + + test "transmitting data" do + @channel.perform_action "action" => :get_latest + + expected = { "identifier" => "{id: 1}", "message" => { "data" => "latest" } } + assert_equal expected, @connection.last_transmission + end + + test "do not send subscription confirmation on initialize" do + assert_nil @connection.last_transmission + end + + test "subscription confirmation on subscribe_to_channel" do + expected = { "identifier" => "{id: 1}", "type" => "confirm_subscription" } + @channel.subscribe_to_channel + assert_equal expected, @connection.last_transmission + end + + test "actions available on Channel" do + available_actions = %w(room last_action subscribed unsubscribed toggle_subscribed leave speak subscribed? get_latest receive chatters topic error_action).to_set + assert_equal available_actions, ChatChannel.action_methods + end + + test "invalid action on Channel" do + assert_logged("Unable to process ActionCable::Channel::BaseTest::ChatChannel#invalid_action") do + @channel.perform_action "action" => :invalid_action + end + end + + test "notification for perform_action" do + data = { "action" => :speak, "content" => "hello" } + expected_payload = { channel_class: "ActionCable::Channel::BaseTest::ChatChannel", action: :speak, data: } + + assert_notifications_count("perform_action.action_cable", 1) do + assert_notification("perform_action.action_cable", expected_payload) do + @channel.perform_action data + end + end + end + + test "notification for transmit" do + data = { data: "latest" } + expected_payload = { channel_class: "ActionCable::Channel::BaseTest::ChatChannel", data:, via: nil } + + assert_notifications_count("transmit.action_cable", 1) do + assert_notification("transmit.action_cable", expected_payload) do + @channel.perform_action "action" => :get_latest + end + end + end + + test "notification for transmit_subscription_confirmation" do + expected_payload = { channel_class: "ActionCable::Channel::BaseTest::ChatChannel", identifier: "{id: 1}" } + + @channel.subscribe_to_channel + + assert_notifications_count("transmit_subscription_confirmation.action_cable", 1) do + assert_notification("transmit_subscription_confirmation.action_cable", expected_payload) do + @channel.stub(:subscription_confirmation_sent?, false) do + @channel.send(:transmit_subscription_confirmation) + end + end + end + end + + test "notification for transmit_subscription_rejection" do + expected_payload = { channel_class: "ActionCable::Channel::BaseTest::ChatChannel", identifier: "{id: 1}" } + + assert_notifications_count("transmit_subscription_rejection.action_cable", 1) do + assert_notification("transmit_subscription_rejection.action_cable", expected_payload) do + @channel.send(:transmit_subscription_rejection) + end + end + end + + test "behaves like rescuable" do + @channel.perform_action "action" => :error_action + assert_equal [ :error_action ], @channel.last_action + end + + private + def assert_logged(message) + old_logger = @connection.logger + log = StringIO.new + @connection.instance_variable_set(:@logger, Logger.new(log)) + + begin + yield + + log.rewind + assert_match message, log.read + ensure + @connection.instance_variable_set(:@logger, old_logger) + end + end +end diff --git a/actioncable/test/channel/broadcasting_test.rb b/actioncable/test/channel/broadcasting_test.rb new file mode 100644 index 0000000000000..fb501a1bc277a --- /dev/null +++ b/actioncable/test/channel/broadcasting_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_connection" +require "stubs/room" + +class ActionCable::Channel::BroadcastingTest < ActionCable::TestCase + class ChatChannel < ActionCable::Channel::Base + end + + setup do + @connection = TestConnection.new + end + + test "broadcasts_to" do + assert_called_with( + ActionCable.server, + :broadcast, + [ + "action_cable:channel:broadcasting_test:chat:Room#1-Campfire", + "Hello World" + ] + ) do + ChatChannel.broadcast_to(Room.new(1), "Hello World") + end + end + + test "broadcasting_for with an object" do + assert_equal( + "action_cable:channel:broadcasting_test:chat:Room#1-Campfire", + ChatChannel.broadcasting_for(Room.new(1)) + ) + end + + test "broadcasting_for with an array" do + assert_equal( + "action_cable:channel:broadcasting_test:chat:Room#1-Campfire:Room#2-Campfire", + ChatChannel.broadcasting_for([ Room.new(1), Room.new(2) ]) + ) + end + + test "broadcasting_for with a string" do + assert_equal( + "action_cable:channel:broadcasting_test:chat:hello", + ChatChannel.broadcasting_for("hello") + ) + end +end diff --git a/actioncable/test/channel/naming_test.rb b/actioncable/test/channel/naming_test.rb new file mode 100644 index 0000000000000..45652d9cc9db1 --- /dev/null +++ b/actioncable/test/channel/naming_test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionCable::Channel::NamingTest < ActionCable::TestCase + class ChatChannel < ActionCable::Channel::Base + end + + test "channel_name" do + assert_equal "action_cable:channel:naming_test:chat", ChatChannel.channel_name + end +end diff --git a/actioncable/test/channel/periodic_timers_test.rb b/actioncable/test/channel/periodic_timers_test.rb new file mode 100644 index 0000000000000..0c979f4c7c63c --- /dev/null +++ b/actioncable/test/channel/periodic_timers_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_connection" +require "stubs/room" +require "active_support/time" + +class ActionCable::Channel::PeriodicTimersTest < ActionCable::TestCase + class ChatChannel < ActionCable::Channel::Base + # Method name arg + periodically :send_updates, every: 1 + + # Proc arg + periodically -> { ping }, every: 2 + + # Block arg + periodically every: 3 do + ping + end + + private + def ping + end + end + + setup do + @connection = TestConnection.new + end + + test "periodic timers definition" do + timers = ChatChannel.periodic_timers + + assert_equal 3, timers.size + + timers.each_with_index do |timer, i| + assert_kind_of Proc, timer[0] + assert_equal i + 1, timer[1][:every] + end + end + + test "disallow negative and zero periods" do + [ 0, 0.0, 0.seconds, -1, -1.seconds, "foo", :foo, Object.new ].each do |invalid| + e = assert_raise ArgumentError do + ChatChannel.periodically :send_updates, every: invalid + end + assert_match(/Expected every:/, e.message) + end + end + + test "disallow block and arg together" do + e = assert_raise ArgumentError do + ChatChannel.periodically(:send_updates, every: 1) { ping } + end + assert_match(/not both/, e.message) + end + + test "disallow unknown args" do + [ "send_updates", Object.new, nil ].each do |invalid| + e = assert_raise ArgumentError do + ChatChannel.periodically invalid, every: 1 + end + assert_match(/Expected a Symbol/, e.message) + end + end + + test "timer start and stop" do + mock = Minitest::Mock.new + 3.times { mock.expect(:shutdown, nil) } + + assert_called( + @connection.server.event_loop, + :timer, + times: 3, + returns: mock + ) do + channel = ChatChannel.new @connection, "{id: 1}", id: 1 + + channel.subscribe_to_channel + channel.unsubscribe_from_channel + assert_equal [], channel.send(:active_periodic_timers) + end + + assert mock.verify + end +end diff --git a/actioncable/test/channel/rejection_test.rb b/actioncable/test/channel/rejection_test.rb new file mode 100644 index 0000000000000..683eafcac02c7 --- /dev/null +++ b/actioncable/test/channel/rejection_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/mock" +require "stubs/test_connection" +require "stubs/room" + +class ActionCable::Channel::RejectionTest < ActionCable::TestCase + class SecretChannel < ActionCable::Channel::Base + def subscribed + reject if params[:id] > 0 + end + + def secret_action + end + end + + setup do + @user = User.new "lifo" + @connection = TestConnection.new(@user) + end + + test "subscription rejection" do + subscriptions = Minitest::Mock.new + subscriptions.expect(:remove_subscription, SecretChannel, [SecretChannel]) + + @connection.stub(:subscriptions, subscriptions) do + @channel = SecretChannel.new @connection, "{id: 1}", id: 1 + @channel.subscribe_to_channel + + expected = { "identifier" => "{id: 1}", "type" => "reject_subscription" } + assert_equal expected, @connection.last_transmission + end + + assert subscriptions.verify + end + + test "does not execute action if subscription is rejected" do + subscriptions = Minitest::Mock.new + subscriptions.expect(:remove_subscription, SecretChannel, [SecretChannel]) + + @connection.stub(:subscriptions, subscriptions) do + @channel = SecretChannel.new @connection, "{id: 1}", id: 1 + @channel.subscribe_to_channel + + expected = { "identifier" => "{id: 1}", "type" => "reject_subscription" } + assert_equal expected, @connection.last_transmission + assert_equal 1, @connection.transmissions.size + + @channel.perform_action("action" => :secret_action) + assert_equal 1, @connection.transmissions.size + end + + assert subscriptions.verify + end +end diff --git a/actioncable/test/channel/stream_test.rb b/actioncable/test/channel/stream_test.rb new file mode 100644 index 0000000000000..6b520695e5f0e --- /dev/null +++ b/actioncable/test/channel/stream_test.rb @@ -0,0 +1,370 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/mock" +require "stubs/test_connection" +require "stubs/room" + +module ActionCable::StreamTests + class Connection < ActionCable::Connection::Base + attr_reader :websocket + + def send_async(method, *args) + send method, *args + end + end + + class ChatChannel < ActionCable::Channel::Base + def subscribed + if params[:id] + @room = Room.new params[:id] + stream_from "test_room_#{@room.id}", coder: pick_coder(params[:coder]) + end + end + + def send_confirmation + transmit_subscription_confirmation + end + + private + def pick_coder(coder) + case coder + when nil, "json" + ActiveSupport::JSON + when "custom" + DummyEncoder + when "none" + nil + end + end + end + + module DummyEncoder + extend self + def encode(*) '{ "foo": "encoded" }' end + def decode(*) { foo: "decoded" } end + end + + class SymbolChannel < ActionCable::Channel::Base + def subscribed + stream_from :channel + end + end + + class StreamTest < ActionCable::TestCase + test "streaming start and stop" do + run_in_eventmachine do + connection = TestConnection.new + pubsub = Minitest::Mock.new connection.pubsub + + pubsub.expect(:subscribe, nil, ["test_room_1", Proc, Proc]) + pubsub.expect(:unsubscribe, nil, ["test_room_1", Proc]) + + connection.stub(:pubsub, pubsub) do + channel = ChatChannel.new connection, "{id: 1}", id: 1 + channel.subscribe_to_channel + + wait_for_async + channel.unsubscribe_from_channel + end + + assert pubsub.verify + end + end + + test "stream from non-string channel" do + run_in_eventmachine do + connection = TestConnection.new + pubsub = Minitest::Mock.new connection.pubsub + + pubsub.expect(:subscribe, nil, ["channel", Proc, Proc]) + pubsub.expect(:unsubscribe, nil, ["channel", Proc]) + + connection.stub(:pubsub, pubsub) do + channel = SymbolChannel.new connection, "" + channel.subscribe_to_channel + + wait_for_async + + channel.unsubscribe_from_channel + end + + assert pubsub.verify + end + end + + test "stream_for" do + run_in_eventmachine do + connection = TestConnection.new + + channel = ChatChannel.new connection, "" + channel.subscribe_to_channel + channel.stream_for Room.new(1) + wait_for_async + + pubsub_call = channel.pubsub.class.class_variable_get "@@subscribe_called" + + assert_equal "action_cable:stream_tests:chat:Room#1-Campfire", pubsub_call[:channel] + assert_instance_of Proc, pubsub_call[:callback] + assert_instance_of Proc, pubsub_call[:success_callback] + end + end + + test "stream_or_reject_for" do + run_in_eventmachine do + connection = TestConnection.new + + channel = ChatChannel.new connection, "" + channel.subscribe_to_channel + channel.stream_or_reject_for Room.new(1) + wait_for_async + + pubsub_call = channel.pubsub.class.class_variable_get "@@subscribe_called" + + assert_equal "action_cable:stream_tests:chat:Room#1-Campfire", pubsub_call[:channel] + assert_instance_of Proc, pubsub_call[:callback] + assert_instance_of Proc, pubsub_call[:success_callback] + end + end + + test "reject subscription when nil is passed to stream_or_reject_for" do + run_in_eventmachine do + connection = TestConnection.new + channel = ChatChannel.new connection, "{id: 1}", id: 1 + channel.subscribe_to_channel + channel.stream_or_reject_for nil + assert_nil connection.last_transmission + + wait_for_async + + rejection = { "identifier" => "{id: 1}", "type" => "reject_subscription" } + connection.transmit(rejection) + assert_equal rejection, connection.last_transmission + end + end + + test "stream_from subscription confirmation" do + run_in_eventmachine do + connection = TestConnection.new + + channel = ChatChannel.new connection, "{id: 1}", id: 1 + channel.subscribe_to_channel + + assert_nil connection.last_transmission + + wait_for_async + + confirmation = { "identifier" => "{id: 1}", "type" => "confirm_subscription" } + connection.transmit(confirmation) + + assert_equal confirmation, connection.last_transmission, "Did not receive subscription confirmation within 0.1s" + end + end + + test "subscription confirmation should only be sent out once" do + run_in_eventmachine do + connection = TestConnection.new + + channel = ChatChannel.new connection, "test_channel" + channel.send_confirmation + channel.send_confirmation + + wait_for_async + + expected = { "identifier" => "test_channel", "type" => "confirm_subscription" } + assert_equal expected, connection.last_transmission, "Did not receive subscription confirmation" + + assert_equal 1, connection.transmissions.size + end + end + + test "stop_all_streams" do + run_in_eventmachine do + connection = TestConnection.new + + channel = ChatChannel.new connection, "{id: 3}" + channel.subscribe_to_channel + + assert_equal 0, subscribers_of(connection).size + + channel.stream_from "room_one" + channel.stream_from "room_two" + + wait_for_async + assert_equal 2, subscribers_of(connection).size + + channel2 = ChatChannel.new connection, "{id: 3}" + channel2.subscribe_to_channel + + channel2.stream_from "room_one" + wait_for_async + + subscribers = subscribers_of(connection) + + assert_equal 2, subscribers.size + assert_equal 2, subscribers["room_one"].size + assert_equal 1, subscribers["room_two"].size + + channel.stop_all_streams + + subscribers = subscribers_of(connection) + assert_equal 1, subscribers.size + assert_equal 1, subscribers["room_one"].size + end + end + + test "stop_stream_from" do + run_in_eventmachine do + connection = TestConnection.new + + channel = ChatChannel.new connection, "{id: 3}" + channel.subscribe_to_channel + + channel.stream_from "room_one" + channel.stream_from "room_two" + + channel2 = ChatChannel.new connection, "{id: 3}" + channel2.subscribe_to_channel + + channel2.stream_from "room_one" + + subscribers = subscribers_of(connection) + + wait_for_async + + assert_equal 2, subscribers.size + assert_equal 2, subscribers["room_one"].size + assert_equal 1, subscribers["room_two"].size + + channel.stop_stream_from "room_one" + + subscribers = subscribers_of(connection) + + assert_equal 2, subscribers.size + assert_equal 1, subscribers["room_one"].size + assert_equal 1, subscribers["room_two"].size + end + end + + test "stop_stream_for" do + run_in_eventmachine do + connection = TestConnection.new + + channel = ChatChannel.new connection, "{id: 3}" + channel.subscribe_to_channel + + channel.stream_for Room.new(1) + channel.stream_for Room.new(2) + + channel2 = ChatChannel.new connection, "{id: 3}" + channel2.subscribe_to_channel + + channel2.stream_for Room.new(1) + + subscribers = subscribers_of(connection) + + wait_for_async + + assert_equal 2, subscribers.size + + assert_equal 2, subscribers[ChatChannel.broadcasting_for(Room.new(1))].size + assert_equal 1, subscribers[ChatChannel.broadcasting_for(Room.new(2))].size + + channel.stop_stream_for Room.new(1) + + subscribers = subscribers_of(connection) + + assert_equal 2, subscribers.size + assert_equal 1, subscribers[ChatChannel.broadcasting_for(Room.new(1))].size + assert_equal 1, subscribers[ChatChannel.broadcasting_for(Room.new(2))].size + end + end + + private + def subscribers_of(connection) + connection + .pubsub + .subscriber_map + end + end + + class UserCallbackChannel < ActionCable::Channel::Base + def subscribed + stream_from :channel do + Thread.current[:ran_callback] = true + end + end + end + + class MultiChatChannel < ActionCable::Channel::Base + def subscribed + stream_from "main_room" + stream_from "test_all_rooms" + end + end + + class StreamFromTest < ActionCable::TestCase + setup do + @server = TestServer.new(subscription_adapter: ActionCable::SubscriptionAdapter::Async) + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + end + + test "custom encoder" do + run_in_eventmachine do + connection = open_connection + subscribe_to connection, identifiers: { id: 1 } + + assert_called(connection.websocket, :transmit) do + @server.broadcast "test_room_1", { foo: "bar" }, coder: DummyEncoder + wait_for_async + wait_for_executor connection.server.worker_pool.executor + end + end + end + + test "user supplied callbacks are run through the worker pool" do + run_in_eventmachine do + connection = open_connection + receive(connection, command: "subscribe", channel: UserCallbackChannel.name, identifiers: { id: 1 }) + + @server.broadcast "channel", {} + wait_for_async + assert_not Thread.current[:ran_callback], "User callback was not run through the worker pool" + end + end + + test "subscription confirmation should only be sent out once with multiple stream_from" do + run_in_eventmachine do + connection = open_connection + expected = { "identifier" => { "channel" => MultiChatChannel.name }.to_json, "type" => "confirm_subscription" } + assert_called_with(connection.websocket, :transmit, [expected.to_json]) do + receive(connection, command: "subscribe", channel: MultiChatChannel.name, identifiers: {}) + wait_for_async + end + end + end + + private + def subscribe_to(connection, identifiers:) + receive connection, command: "subscribe", identifiers: identifiers + end + + def open_connection + env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", "HTTP_ORIGIN" => "http://rubyonrails.com/" + + Connection.new(@server, env).tap do |connection| + connection.process + assert_predicate connection.websocket, :possible? + + wait_for_async + assert_predicate connection.websocket, :alive? + end + end + + def receive(connection, command:, identifiers:, channel: "ActionCable::StreamTests::ChatChannel") + identifier = JSON.generate(identifiers.merge(channel: channel)) + connection.dispatch_websocket_message JSON.generate(command: command, identifier: identifier) + wait_for_async + end + end +end diff --git a/actioncable/test/channel/test_case_test.rb b/actioncable/test/channel/test_case_test.rb new file mode 100644 index 0000000000000..48ef86998bf54 --- /dev/null +++ b/actioncable/test/channel/test_case_test.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestTestChannel < ActionCable::Channel::Base +end + +class NonInferrableExplicitClassChannelTest < ActionCable::Channel::TestCase + tests TestTestChannel + + def test_set_channel_class_manual + assert_equal TestTestChannel, self.class.channel_class + end +end + +class NonInferrableSymbolNameChannelTest < ActionCable::Channel::TestCase + tests :test_test_channel + + def test_set_channel_class_manual_using_symbol + assert_equal TestTestChannel, self.class.channel_class + end +end + +class NonInferrableStringNameChannelTest < ActionCable::Channel::TestCase + tests "test_test_channel" + + def test_set_channel_class_manual_using_string + assert_equal TestTestChannel, self.class.channel_class + end +end + +class SubscriptionsTestChannel < ActionCable::Channel::Base +end + +class SubscriptionsTestChannelTest < ActionCable::Channel::TestCase + def setup + stub_connection + end + + def test_no_subscribe + assert_nil subscription + end + + def test_subscribe + subscribe + + assert_predicate subscription, :confirmed? + assert_not subscription.rejected? + assert_equal 1, connection.transmissions.size + assert_equal ActionCable::INTERNAL[:message_types][:confirmation], + connection.transmissions.last["type"] + end +end + +class StubConnectionTest < ActionCable::Channel::TestCase + tests SubscriptionsTestChannel + + def test_connection_identifiers + stub_connection username: "John", admin: true + + subscribe + + assert_equal "John", subscription.username + assert subscription.admin + assert_equal "John:true", connection.connection_identifier + end +end + +class RejectionTestChannel < ActionCable::Channel::Base + def subscribed + reject + end +end + +class RejectionTestChannelTest < ActionCable::Channel::TestCase + def test_rejection + subscribe + + assert_not subscription.confirmed? + assert_predicate subscription, :rejected? + assert_equal 1, connection.transmissions.size + assert_equal ActionCable::INTERNAL[:message_types][:rejection], + connection.transmissions.last["type"] + end +end + +class StreamsTestChannel < ActionCable::Channel::Base + def subscribed + stream_from "test_#{params[:id] || 0}" + end + + def unsubscribed + stop_stream_from "test_#{params[:id] || 0}" + end +end + +class StreamsTestChannelTest < ActionCable::Channel::TestCase + def test_stream_without_params + subscribe + + assert_has_stream "test_0" + end + + def test_stream_with_params + subscribe id: 42 + + assert_has_stream "test_42" + end + + def test_not_stream_without_params + subscribe + unsubscribe + + assert_has_no_stream "test_0" + end + + def test_not_stream_with_params + subscribe id: 42 + perform :unsubscribed, id: 42 + + assert_has_no_stream "test_42" + end + + def test_unsubscribe_from_stream + subscribe + unsubscribe + + assert_no_streams + end +end + +class StreamsForTestChannel < ActionCable::Channel::Base + def subscribed + stream_for User.new(params[:id]) + end + + def unsubscribed + stop_stream_for User.new(params[:id]) + end +end + +class StreamsForTestChannelTest < ActionCable::Channel::TestCase + def test_stream_with_params + subscribe id: 42 + + assert_has_stream_for User.new(42) + end + + def test_not_stream_with_params + subscribe id: 42 + perform :unsubscribed, id: 42 + + assert_has_no_stream_for User.new(42) + end +end + +class NoStreamsTestChannel < ActionCable::Channel::Base + def subscribed; end # no-op +end + +class NoStreamsTestChannelTest < ActionCable::Channel::TestCase + def test_stream_with_params + subscribe + + assert_no_streams + end +end + +class PerformTestChannel < ActionCable::Channel::Base + def echo(data) + data.delete("action") + transmit data + end + + def ping + transmit({ type: "pong" }) + end +end + +class PerformTestChannelTest < ActionCable::Channel::TestCase + def setup + stub_connection user_id: 2016 + subscribe id: 5 + end + + def test_perform_with_params + perform :echo, text: "You are man!" + + assert_equal({ "text" => "You are man!" }, transmissions.last) + end + + def test_perform_and_transmit + perform :ping + + assert_equal "pong", transmissions.last["type"] + end +end + +class PerformUnsubscribedTestChannelTest < ActionCable::Channel::TestCase + tests PerformTestChannel + + def test_perform_when_unsubscribed + assert_raises do + perform :echo + end + end +end + +class BroadcastsTestChannel < ActionCable::Channel::Base + def broadcast(data) + ActionCable.server.broadcast( + "broadcast_#{params[:id]}", + { text: data["message"], user_id: user_id } + ) + end + + def broadcast_to_user(data) + user = User.new user_id + + broadcast_to user, text: data["message"] + end +end + +class BroadcastsTestChannelTest < ActionCable::Channel::TestCase + def setup + stub_connection user_id: 2017 + subscribe id: 5 + end + + def test_broadcast_matchers_included + assert_broadcast_on("broadcast_5", user_id: 2017, text: "SOS") do + perform :broadcast, message: "SOS" + end + end + + def test_broadcast_to_object + user = User.new(2017) + + assert_broadcasts(user, 1) do + perform :broadcast_to_user, text: "SOS" + end + end + + def test_broadcast_to_object_with_data + user = User.new(2017) + + assert_broadcast_on(user, text: "SOS") do + perform :broadcast_to_user, message: "SOS" + end + end +end diff --git a/actioncable/test/client_test.rb b/actioncable/test/client_test.rb new file mode 100644 index 0000000000000..9069510c89311 --- /dev/null +++ b/actioncable/test/client_test.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +require "test_helper" +require "concurrent" + +require "websocket-client-simple" +require "json" + +require "active_support/hash_with_indifferent_access" + +class ClientTest < ActionCable::TestCase + WAIT_WHEN_EXPECTING_EVENT = 2 + WAIT_WHEN_NOT_EXPECTING_EVENT = 0.5 + + class Connection < ActionCable::Connection::Base + identified_by :id + + def connect + self.id = request.params["id"] || SecureRandom.hex(4) + end + end + + class EchoChannel < ActionCable::Channel::Base + def subscribed + stream_from "global" + end + + def unsubscribed + "Goodbye from EchoChannel!" + end + + def ding(data) + transmit({ dong: data["message"] }) + end + + def delay(data) + sleep 1 + transmit({ dong: data["message"] }) + end + + def bulk(data) + ActionCable.server.broadcast "global", { wide: data["message"] } + end + end + + def setup + ActionCable.instance_variable_set(:@server, nil) + server = ActionCable.server + server.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + + server.config.cable = ActiveSupport::HashWithIndifferentAccess.new(adapter: "async") + server.config.connection_class = -> { ClientTest::Connection } + + # and now the "real" setup for our test: + server.config.disable_request_forgery_protection = true + end + + def with_puma_server(rack_app = ActionCable.server, port = 3099) + opts = { min_threads: 1, max_threads: 4 } + server = if Puma::Const::PUMA_VERSION >= "6" + opts[:log_writer] = ::Puma::LogWriter.strings + ::Puma::Server.new(rack_app, nil, opts) + else + # Puma >= 5.0.3 + ::Puma::Server.new(rack_app, ::Puma::Events.strings, opts) + end + server.add_tcp_listener "127.0.0.1", port + + thread = server.run + + begin + yield port + + ensure + server.stop + + begin + thread.join + + rescue IOError + # Work around https://bugs.ruby-lang.org/issues/13405 + # + # Puma's sometimes raising while shutting down, when it closes + # its internal pipe. We can safely ignore that, but we do need + # to do the step skipped by the exception: + server.binder.close + + rescue RuntimeError => ex + # Work around https://bugs.ruby-lang.org/issues/13239 + raise unless ex.message.match?(/can't modify frozen IOError/) + + # Handle this as if it were the IOError: do the same as above. + server.binder.close + end + end + end + + class SyncClient + attr_reader :pings + + def initialize(port, path = "/") + messages = @messages = Queue.new + closed = @closed = Concurrent::Event.new + has_messages = @has_messages = Concurrent::Semaphore.new(0) + pings = @pings = Concurrent::AtomicFixnum.new(0) + + open = Concurrent::Promise.new + + @ws = WebSocket::Client::Simple.connect("ws://127.0.0.1:#{port}#{path}") do |ws| + ws.on(:error) do |event| + event = RuntimeError.new(event.message) unless event.is_a?(Exception) + + if open.pending? + open.fail(event) + else + messages << event + has_messages.release + end + end + + ws.on(:open) do |event| + open.set(true) + end + + ws.on(:message) do |event| + if event.type == :close + closed.set + else + message = JSON.parse(event.data) + if message["type"] == "ping" + pings.increment + else + messages << message + has_messages.release + end + end + end + + ws.on(:close) do |_| + closed.set + end + end + + open.wait!(WAIT_WHEN_EXPECTING_EVENT) + end + + def read_message + @has_messages.try_acquire(1, WAIT_WHEN_EXPECTING_EVENT) + + msg = @messages.pop(true) + raise msg if msg.is_a?(Exception) + + msg + end + + def read_messages(expected_size = 0) + list = [] + loop do + if @has_messages.try_acquire(1, list.size < expected_size ? WAIT_WHEN_EXPECTING_EVENT : WAIT_WHEN_NOT_EXPECTING_EVENT) + msg = @messages.pop(true) + raise msg if msg.is_a?(Exception) + + list << msg + else + break + end + end + list + end + + def send_message(message) + @ws.send(JSON.generate(message)) + end + + def close + sleep WAIT_WHEN_NOT_EXPECTING_EVENT + + unless @messages.empty? + raise "#{@messages.size} messages unprocessed" + end + + @ws.close + wait_for_close + end + + def wait_for_close + @closed.wait(WAIT_WHEN_EXPECTING_EVENT) + end + + def closed? + @closed.set? + end + end + + def websocket_client(*args) + SyncClient.new(*args) + end + + def concurrently(enum) + enum.map { |*x| Concurrent::Promises.future { yield(*x) } }.map(&:value!) + end + + def test_single_client + with_puma_server do |port| + c = websocket_client(port) + assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack + c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") + assert_equal({ "identifier" => "{\"channel\":\"ClientTest::EchoChannel\"}", "type" => "confirm_subscription" }, c.read_message) + c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "ding", message: "hello") + assert_equal({ "identifier" => "{\"channel\":\"ClientTest::EchoChannel\"}", "message" => { "dong" => "hello" } }, c.read_message) + c.close + end + end + + def test_interacting_clients + with_puma_server do |port| + clients = concurrently(10.times) { websocket_client(port) } + + barrier_1 = Concurrent::CyclicBarrier.new(clients.size) + barrier_2 = Concurrent::CyclicBarrier.new(clients.size) + + concurrently(clients) do |c| + assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack + c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") + assert_equal({ "identifier" => '{"channel":"ClientTest::EchoChannel"}', "type" => "confirm_subscription" }, c.read_message) + c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "ding", message: "hello") + assert_equal({ "identifier" => '{"channel":"ClientTest::EchoChannel"}', "message" => { "dong" => "hello" } }, c.read_message) + barrier_1.wait WAIT_WHEN_EXPECTING_EVENT + c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "bulk", message: "hello") + barrier_2.wait WAIT_WHEN_EXPECTING_EVENT + assert_equal clients.size, c.read_messages(clients.size).size + end + + concurrently(clients, &:close) + end + end + + def test_many_clients + with_puma_server do |port| + clients = concurrently(100.times) { websocket_client(port) } + + concurrently(clients) do |c| + assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack + c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") + assert_equal({ "identifier" => '{"channel":"ClientTest::EchoChannel"}', "type" => "confirm_subscription" }, c.read_message) + c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "ding", message: "hello") + assert_equal({ "identifier" => '{"channel":"ClientTest::EchoChannel"}', "message" => { "dong" => "hello" } }, c.read_message) + end + + concurrently(clients, &:close) + end + end + + def test_disappearing_client + with_puma_server do |port| + c = websocket_client(port) + assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack + c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") + assert_equal({ "identifier" => "{\"channel\":\"ClientTest::EchoChannel\"}", "type" => "confirm_subscription" }, c.read_message) + c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "delay", message: "hello") + c.close # disappear before write + + c = websocket_client(port) + assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack + c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") + assert_equal({ "identifier" => "{\"channel\":\"ClientTest::EchoChannel\"}", "type" => "confirm_subscription" }, c.read_message) + c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "ding", message: "hello") + assert_equal({ "identifier" => '{"channel":"ClientTest::EchoChannel"}', "message" => { "dong" => "hello" } }, c.read_message) + c.close # disappear before read + end + end + + def test_unsubscribe_client + with_puma_server do |port| + app = ActionCable.server + identifier = JSON.generate(channel: "ClientTest::EchoChannel") + + c = websocket_client(port) + assert_equal({ "type" => "welcome" }, c.read_message) + c.send_message command: "subscribe", identifier: identifier + assert_equal({ "identifier" => "{\"channel\":\"ClientTest::EchoChannel\"}", "type" => "confirm_subscription" }, c.read_message) + assert_equal(1, app.connections.count) + + subscriptions = app.connections.first.subscriptions.send(:subscriptions) + assert_not_equal 0, subscriptions.size, "Missing EchoChannel subscription" + channel = subscriptions.first[1] + assert_called(channel, :unsubscribed) do + c.close + sleep 0.1 # Data takes a moment to process + end + + # All data is removed: No more connection or subscription information! + assert_equal(0, app.connections.count) + end + end + + def test_remote_disconnect_client + with_puma_server do |port| + app = ActionCable.server + + c = websocket_client(port, "/?id=1") + assert_equal({ "type" => "welcome" }, c.read_message) + + sleep 0.1 # make sure connections is registered + app.remote_connections.where(id: "1").disconnect + + assert_equal({ "type" => "disconnect", "reason" => "remote", "reconnect" => true }, c.read_message) + + c.wait_for_close + assert_predicate(c, :closed?) + end + end + + def test_remote_disconnect_client_with_reconnect + with_puma_server do |port| + app = ActionCable.server + + c = websocket_client(port, "/?id=2") + assert_equal({ "type" => "welcome" }, c.read_message) + + sleep 0.1 # make sure connections is registered + app.remote_connections.where(id: "2").disconnect(reconnect: false) + + assert_equal({ "type" => "disconnect", "reason" => "remote", "reconnect" => false }, c.read_message) + + c.wait_for_close + assert_predicate(c, :closed?) + end + end + + def test_server_restart + with_puma_server do |port| + c = websocket_client(port) + assert_equal({ "type" => "welcome" }, c.read_message) + c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") + assert_equal({ "identifier" => "{\"channel\":\"ClientTest::EchoChannel\"}", "type" => "confirm_subscription" }, c.read_message) + + ActionCable.server.restart + c.wait_for_close + assert_predicate c, :closed? + end + end +end diff --git a/actioncable/test/connection/authorization_test.rb b/actioncable/test/connection/authorization_test.rb new file mode 100644 index 0000000000000..ac5c128135cda --- /dev/null +++ b/actioncable/test/connection/authorization_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class ActionCable::Connection::AuthorizationTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + attr_reader :websocket + + def connect + reject_unauthorized_connection + end + + def send_async(method, *args) + send method, *args + end + end + + test "unauthorized connection" do + run_in_eventmachine do + server = TestServer.new + server.config.allowed_request_origins = %w( http://rubyonrails.com ) + + env = Rack::MockRequest.env_for "/test", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", + "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.com/" + + connection = Connection.new(server, env) + + assert_called_with(connection.websocket, :transmit, [{ type: "disconnect", reason: "unauthorized", reconnect: false }.to_json]) do + assert_called(connection.websocket, :close) do + connection.process + end + end + end + end +end diff --git a/actioncable/test/connection/base_test.rb b/actioncable/test/connection/base_test.rb new file mode 100644 index 0000000000000..af0e88aae1c19 --- /dev/null +++ b/actioncable/test/connection/base_test.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" +require "active_support/core_ext/object/json" + +class ActionCable::Connection::BaseTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + attr_reader :websocket, :subscriptions, :message_buffer, :connected + + def connect + @connected = true + end + + def disconnect + @connected = false + end + + def send_async(method, *args) + send method, *args + end + end + + setup do + @server = TestServer.new + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + end + + test "making a connection with invalid headers" do + run_in_eventmachine do + connection = ActionCable::Connection::Base.new(@server, Rack::MockRequest.env_for("/test")) + response = connection.process + assert_equal 404, response[0] + end + end + + test "websocket connection" do + run_in_eventmachine do + connection = open_connection + connection.process + + assert_predicate connection.websocket, :possible? + + wait_for_async + assert_predicate connection.websocket, :alive? + end + end + + test "rack response" do + run_in_eventmachine do + connection = open_connection + response = connection.process + + assert_equal [ -1, {}, [] ], response + end + end + + test "on connection open" do + run_in_eventmachine do + connection = open_connection + + assert_called_with(connection.websocket, :transmit, [{ type: "welcome" }.to_json]) do + assert_called(connection.message_buffer, :process!) do + connection.process + wait_for_async + end + end + + assert_equal [ connection ], @server.connections + assert connection.connected + end + end + + test "on connection close" do + run_in_eventmachine do + connection = open_connection + connection.process + + # Set up the connection + connection.send :handle_open + assert connection.connected + + assert_called(connection.subscriptions, :unsubscribe_from_all) do + connection.send :handle_close + end + + assert_not connection.connected + assert_equal [], @server.connections + end + end + + test "connection statistics" do + run_in_eventmachine do + connection = open_connection + connection.process + + statistics = connection.statistics + + assert_predicate statistics[:identifier], :blank? + assert_kind_of Time, statistics[:started_at] + assert_equal [], statistics[:subscriptions] + end + end + + test "explicitly closing a connection" do + run_in_eventmachine do + connection = open_connection + connection.process + + assert_called(connection.websocket, :close) do + connection.close(reason: "testing") + end + end + end + + test "rejecting a connection causes a 404" do + run_in_eventmachine do + class CallMeMaybe + def call(*) + raise "Do not call me!" + end + end + + env = Rack::MockRequest.env_for( + "/test", + "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", + "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.org/", "rack.hijack" => CallMeMaybe.new + ) + + connection = ActionCable::Connection::Base.new(@server, env) + response = connection.process + assert_equal 404, response[0] + end + end + + private + def open_connection + env = Rack::MockRequest.env_for "/test", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", + "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.com/" + + Connection.new(@server, env) + end +end diff --git a/actioncable/test/connection/callbacks_test.rb b/actioncable/test/connection/callbacks_test.rb new file mode 100644 index 0000000000000..0552a9ebf1d3e --- /dev/null +++ b/actioncable/test/connection/callbacks_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class ActionCable::Connection::CallbacksTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + identified_by :context + + attr_reader :commands_counter + + before_command do + throw :abort unless context.nil? + end + + around_command :set_current_context + after_command :increment_commands_counter + + def initialize(*) + super + @commands_counter = 0 + end + + private + def set_current_context + self.context = request.params["context"] + yield + ensure + self.context = nil + end + + def increment_commands_counter + @commands_counter += 1 + end + end + + class ChatChannel < ActionCable::Channel::Base + class << self + attr_accessor :words_spoken, :subscribed_count + end + + self.words_spoken = [] + self.subscribed_count = 0 + + def subscribed + self.class.subscribed_count += 1 + end + + def speak(data) + self.class.words_spoken << { data: data, context: context } + end + end + + setup do + @server = TestServer.new + @env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" + @connection = Connection.new(@server, @env) + @identifier = { channel: "ActionCable::Connection::CallbacksTest::ChatChannel" }.to_json + end + + attr_reader :server, :env, :connection, :identifier + + test "before and after callbacks" do + result = assert_difference -> { ChatChannel.subscribed_count }, +1 do + assert_difference -> { connection.commands_counter }, +1 do + connection.handle_channel_command({ "identifier" => identifier, "command" => "subscribe" }) + end + end + assert result + end + + test "before callback halts" do + connection.context = "non_null" + result = assert_no_difference -> { ChatChannel.subscribed_count } do + connection.handle_channel_command({ "identifier" => identifier, "command" => "subscribe" }) + end + assert_not result + end + + test "around_command callback" do + env["QUERY_STRING"] = "context=test" + connection = Connection.new(server, env) + + assert_difference -> { ChatChannel.words_spoken.size }, +1 do + # We need to add subscriptions first + connection.handle_channel_command({ + "identifier" => identifier, + "command" => "subscribe" + }) + connection.handle_channel_command({ + "identifier" => identifier, + "command" => "message", + "data" => { "action" => "speak", "message" => "hello" }.to_json + }) + end + + message = ChatChannel.words_spoken.last + assert_equal({ data: { "action" => "speak", "message" => "hello" }, context: "test" }, message) + end +end diff --git a/actioncable/test/connection/client_socket_test.rb b/actioncable/test/connection/client_socket_test.rb new file mode 100644 index 0000000000000..1ab3c3b71d88f --- /dev/null +++ b/actioncable/test/connection/client_socket_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class ActionCable::Connection::ClientSocketTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + attr_reader :connected, :websocket, :errors + + def initialize(*) + super + @errors = [] + end + + def connect + @connected = true + end + + def disconnect + @connected = false + end + + def send_async(method, *args) + send method, *args + end + + def on_error(message) + @errors << message + end + end + + setup do + @server = TestServer.new + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + end + + test "delegate socket errors to on_error handler" do + run_in_eventmachine do + connection = open_connection + + # Internal hax = :( + client = connection.websocket.send(:websocket) + client.instance_variable_get("@stream").stub(:write, proc { raise "foo" }) do + assert_not_called(client, :client_gone) do + client.write("boo") + end + end + assert_equal %w[ foo ], connection.errors + end + end + + test "closes hijacked i/o socket at shutdown" do + run_in_eventmachine do + connection = open_connection + + client = connection.websocket.send(:websocket) + event = Concurrent::Event.new + client.instance_variable_get("@stream") + .instance_variable_get("@rack_hijack_io") + .define_singleton_method(:close) { event.set } + connection.close(reason: "testing") + event.wait + end + end + + private + def open_connection + env = Rack::MockRequest.env_for "/test", + "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", + "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.com/" + io, client_io = \ + begin + Socket.pair(Socket::AF_UNIX, Socket::SOCK_STREAM, 0) + rescue + StringIO.new + end + env["rack.hijack"] = -> { env["rack.hijack_io"] = io } + + Connection.new(@server, env).tap do |connection| + connection.process + if client_io + # Make sure server returns handshake response + Timeout.timeout(1) do + loop do + break if client_io.readline == "\r\n" + end + end + end + connection.send :handle_open + assert connection.connected + end + end +end diff --git a/actioncable/test/connection/cross_site_forgery_test.rb b/actioncable/test/connection/cross_site_forgery_test.rb new file mode 100644 index 0000000000000..3e21138ffc2b7 --- /dev/null +++ b/actioncable/test/connection/cross_site_forgery_test.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class ActionCable::Connection::CrossSiteForgeryTest < ActionCable::TestCase + HOST = "rubyonrails.com" + + class Connection < ActionCable::Connection::Base + def send_async(method, *args) + send method, *args + end + end + + setup do + @server = TestServer.new + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + @server.config.allow_same_origin_as_host = false + end + + teardown do + @server.config.disable_request_forgery_protection = false + @server.config.allowed_request_origins = [] + @server.config.allow_same_origin_as_host = true + end + + test "disable forgery protection" do + @server.config.disable_request_forgery_protection = true + assert_origin_allowed "http://rubyonrails.com/" + assert_origin_allowed "http://hax.com/" + end + + test "explicitly specified a single allowed origin" do + @server.config.allowed_request_origins = "http://hax.com/" + assert_origin_not_allowed "http://rubyonrails.com/" + assert_origin_allowed "http://hax.com/" + end + + test "explicitly specified multiple allowed origins" do + @server.config.allowed_request_origins = %w( http://rubyonrails.com http://www.rubyonrails.com ) + assert_origin_allowed "http://rubyonrails.com/" + assert_origin_allowed "http://www.rubyonrails.com/" + assert_origin_not_allowed "http://hax.com/" + end + + test "explicitly specified a single regexp allowed origin" do + @server.config.allowed_request_origins = /.*ha.*/ + assert_origin_not_allowed "http://rubyonrails.com/" + assert_origin_allowed "http://hax.com/" + end + + test "explicitly specified multiple regexp allowed origins" do + @server.config.allowed_request_origins = [/http:\/\/ruby.*/, /.*rai.s.*com/, "string" ] + assert_origin_allowed "http://rubyonrails.com/" + assert_origin_allowed "http://www.rubyonrails.com/" + assert_origin_not_allowed "http://hax.com/" + assert_origin_not_allowed "http://rails.co.uk/" + end + + test "allow same origin as host" do + @server.config.allow_same_origin_as_host = true + assert_origin_allowed "http://#{HOST}" + assert_origin_not_allowed "http://hax.com/" + assert_origin_not_allowed "http://rails.co.uk/" + end + + private + def assert_origin_allowed(origin) + response = connect_with_origin origin + assert_equal(-1, response[0]) + end + + def assert_origin_not_allowed(origin) + response = connect_with_origin origin + assert_equal 404, response[0] + end + + def connect_with_origin(origin) + response = nil + + run_in_eventmachine do + response = Connection.new(@server, env_for_origin(origin)).process + end + + response + end + + def env_for_origin(origin) + Rack::MockRequest.env_for "/test", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", "SERVER_NAME" => HOST, + "HTTP_HOST" => HOST, "HTTP_ORIGIN" => origin + end +end diff --git a/actioncable/test/connection/identifier_test.rb b/actioncable/test/connection/identifier_test.rb new file mode 100644 index 0000000000000..707f4bab72e83 --- /dev/null +++ b/actioncable/test/connection/identifier_test.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" +require "stubs/user" + +class ActionCable::Connection::IdentifierTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + identified_by :current_user + attr_reader :websocket + + public :process_internal_message + + def connect + self.current_user = User.new "lifo" + end + end + + test "connection identifier" do + run_in_eventmachine do + open_connection + assert_equal "User#lifo", @connection.connection_identifier + end + end + + test "should subscribe to internal channel on open and unsubscribe on close" do + run_in_eventmachine do + server = TestServer.new + + open_connection(server) + close_connection + wait_for_async + + %w[subscribe unsubscribe].each do |method| + pubsub_call = server.pubsub.class.class_variable_get "@@#{method}_called" + + assert_equal "action_cable/User#lifo", pubsub_call[:channel] + assert_instance_of Proc, pubsub_call[:callback] + end + end + end + + test "processing disconnect message" do + run_in_eventmachine do + open_connection + + assert_called(@connection.websocket, :close) do + @connection.process_internal_message "type" => "disconnect" + end + end + end + + test "processing invalid message" do + run_in_eventmachine do + open_connection + + assert_not_called(@connection.websocket, :close) do + @connection.process_internal_message "type" => "unknown" + end + end + end + + private + def open_connection(server = nil) + server ||= TestServer.new + + env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" + @connection = Connection.new(server, env) + + @connection.process + @connection.send :handle_open + end + + def close_connection + @connection.send :handle_close + end +end diff --git a/actioncable/test/connection/multiple_identifiers_test.rb b/actioncable/test/connection/multiple_identifiers_test.rb new file mode 100644 index 0000000000000..51716410b2dc6 --- /dev/null +++ b/actioncable/test/connection/multiple_identifiers_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" +require "stubs/user" + +class ActionCable::Connection::MultipleIdentifiersTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + identified_by :current_user, :current_room + + def connect + self.current_user = User.new "lifo" + self.current_room = Room.new "my", "room" + end + end + + test "multiple connection identifiers" do + run_in_eventmachine do + open_connection + + assert_equal "Room#my-room:User#lifo", @connection.connection_identifier + end + end + + private + def open_connection + server = TestServer.new + env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" + @connection = Connection.new(server, env) + + @connection.process + @connection.send :handle_open + end +end diff --git a/actioncable/test/connection/stream_test.rb b/actioncable/test/connection/stream_test.rb new file mode 100644 index 0000000000000..c579f1cd683c7 --- /dev/null +++ b/actioncable/test/connection/stream_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/mock" +require "stubs/test_server" + +class ActionCable::Connection::StreamTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + attr_reader :connected, :websocket, :errors + + def initialize(*) + super + @errors = [] + end + + def connect + @connected = true + end + + def disconnect + @connected = false + end + + def send_async(method, *args) + send method, *args + end + + def on_error(message) + @errors << message + end + end + + setup do + @server = TestServer.new + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + end + + [ EOFError, Errno::ECONNRESET ].each do |closed_exception| + test "closes socket on #{closed_exception}" do + run_in_eventmachine do + rack_hijack_io = File.open(File::NULL, "w") + connection = open_connection(rack_hijack_io) + + # Internal hax = :( + client = connection.websocket.send(:websocket) + rack_hijack_io.stub(:write_nonblock, proc { raise(closed_exception, "foo") }) do + assert_called(client, :client_gone) do + client.write("boo") + end + end + assert_equal [], connection.errors + end + end + end + + private + def open_connection(io) + env = Rack::MockRequest.env_for "/test", + "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", + "HTTP_HOST" => "localhost", "HTTP_ORIGIN" => "http://rubyonrails.com/" + env["rack.hijack"] = -> { env["rack.hijack_io"] = io } + + Connection.new(@server, env).tap do |connection| + connection.process + connection.send :handle_open + assert connection.connected + end + end +end diff --git a/actioncable/test/connection/string_identifier_test.rb b/actioncable/test/connection/string_identifier_test.rb new file mode 100644 index 0000000000000..f7019b926aef5 --- /dev/null +++ b/actioncable/test/connection/string_identifier_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class ActionCable::Connection::StringIdentifierTest < ActionCable::TestCase + class Connection < ActionCable::Connection::Base + identified_by :current_token + + def connect + self.current_token = "random-string" + end + + def send_async(method, *args) + send method, *args + end + end + + test "connection identifier" do + run_in_eventmachine do + open_connection + + assert_equal "random-string", @connection.connection_identifier + end + end + + private + def open_connection + server = TestServer.new + env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" + @connection = Connection.new(server, env) + + @connection.process + @connection.send :on_open + end +end diff --git a/actioncable/test/connection/subscriptions_test.rb b/actioncable/test/connection/subscriptions_test.rb new file mode 100644 index 0000000000000..0af3d1be2f9f7 --- /dev/null +++ b/actioncable/test/connection/subscriptions_test.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionCable::Connection::SubscriptionsTest < ActionCable::TestCase + class ChatChannelError < Exception; end + + class Connection < ActionCable::Connection::Base + attr_reader :websocket, :exceptions + + rescue_from ChatChannelError, with: :error_handler + + def initialize(*) + super + @exceptions = [] + end + + def send_async(method, *args) + send method, *args + end + + def error_handler(e) + @exceptions << e + end + end + + class ChatChannel < ActionCable::Channel::Base + attr_reader :room, :lines + + def subscribed + @room = Room.new params[:id] + @lines = [] + end + + def speak(data) + @lines << data + end + + def throw_exception(_data) + raise ChatChannelError.new("Uh Oh") + end + end + + setup do + @server = TestServer.new + + @chat_identifier = ActiveSupport::JSON.encode(id: 1, channel: "ActionCable::Connection::SubscriptionsTest::ChatChannel") + end + + test "subscribe command" do + run_in_eventmachine do + setup_connection + channel = subscribe_to_chat_channel + + assert_kind_of ChatChannel, channel + assert_equal 1, channel.room.id + end + end + + test "subscribe command without an identifier" do + run_in_eventmachine do + setup_connection + + @subscriptions.execute_command "command" => "subscribe" + assert_empty @subscriptions.identifiers + end + end + + test "subscribe command with Base channel" do + run_in_eventmachine do + setup_connection + + identifier = ActiveSupport::JSON.encode(id: 1, channel: "ActionCable::Channel::Base") + @subscriptions.execute_command "command" => "subscribe", "identifier" => identifier + + assert_empty @subscriptions.identifiers + end + end + + test "unsubscribe command" do + run_in_eventmachine do + setup_connection + subscribe_to_chat_channel + + channel = subscribe_to_chat_channel + + assert_called(channel, :unsubscribe_from_channel) do + @subscriptions.execute_command "command" => "unsubscribe", "identifier" => @chat_identifier + end + + assert_empty @subscriptions.identifiers + end + end + + test "unsubscribe command without an identifier" do + run_in_eventmachine do + setup_connection + + @subscriptions.execute_command "command" => "unsubscribe" + assert_empty @subscriptions.identifiers + end + end + + test "message command" do + run_in_eventmachine do + setup_connection + channel = subscribe_to_chat_channel + + data = { "content" => "Hello World!", "action" => "speak" } + @subscriptions.execute_command "command" => "message", "identifier" => @chat_identifier, "data" => ActiveSupport::JSON.encode(data) + + assert_equal [ data ], channel.lines + end + end + + test "accessing exceptions thrown during command execution" do + run_in_eventmachine do + setup_connection + subscribe_to_chat_channel + + data = { "content" => "Hello World!", "action" => "throw_exception" } + @subscriptions.execute_command "command" => "message", "identifier" => @chat_identifier, "data" => ActiveSupport::JSON.encode(data) + + exception = @connection.exceptions.first + assert_kind_of ChatChannelError, exception + end + end + + test "unsubscribe from all" do + run_in_eventmachine do + setup_connection + + channel1 = subscribe_to_chat_channel + + channel2_id = ActiveSupport::JSON.encode(id: 2, channel: "ActionCable::Connection::SubscriptionsTest::ChatChannel") + channel2 = subscribe_to_chat_channel(channel2_id) + + assert_called(channel1, :unsubscribe_from_channel) do + assert_called(channel2, :unsubscribe_from_channel) do + @subscriptions.unsubscribe_from_all + end + end + end + end + + private + def subscribe_to_chat_channel(identifier = @chat_identifier) + @subscriptions.execute_command "command" => "subscribe", "identifier" => identifier + assert_equal identifier, @subscriptions.identifiers.last + + @subscriptions.send :find, "identifier" => identifier + end + + def setup_connection + env = Rack::MockRequest.env_for "/test", "HTTP_HOST" => "localhost", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket" + @connection = Connection.new(@server, env) + + @subscriptions = ActionCable::Connection::Subscriptions.new(@connection) + end +end diff --git a/actioncable/test/connection/test_case_test.rb b/actioncable/test/connection/test_case_test.rb new file mode 100644 index 0000000000000..7852fdeca7d01 --- /dev/null +++ b/actioncable/test/connection/test_case_test.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require "test_helper" + +class SimpleConnection < ActionCable::Connection::Base + identified_by :user_id + + class << self + attr_accessor :disconnected_user_id + end + + def connect + self.user_id = request.params[:user_id] || cookies[:user_id] + end + + def disconnect + self.class.disconnected_user_id = user_id + end +end + +class ConnectionSimpleTest < ActionCable::Connection::TestCase + tests SimpleConnection + + def test_connected + connect + + assert_nil connection.user_id + end + + def test_url_params + connect "/cable?user_id=323" + + assert_equal "323", connection.user_id + end + + def test_params + connect params: { user_id: 323 } + + assert_equal "323", connection.user_id + end + + def test_plain_cookie + cookies["user_id"] = "456" + + connect + + assert_equal "456", connection.user_id + end + + def test_plain_cookie_with_explicit_value_and_string_key + cookies["user_id"] = { "value" => "456" } + + connect + + assert_equal "456", connection.user_id + end + + def test_disconnect + cookies["user_id"] = "456" + + connect + + assert_equal "456", connection.user_id + + disconnect + + assert_equal "456", SimpleConnection.disconnected_user_id + end +end + +class Connection < ActionCable::Connection::Base + identified_by :current_user_id + identified_by :token + + class << self + attr_accessor :disconnected_user_id + end + + def connect + self.current_user_id = verify_user + self.token = request.headers["X-API-TOKEN"] + logger.add_tags("ActionCable") + end + + private + def verify_user + cookies.signed[:user_id].presence || reject_unauthorized_connection + end +end + +class ConnectionTest < ActionCable::Connection::TestCase + def test_connected_with_signed_cookies_and_headers + cookies.signed["user_id"] = "456" + + connect headers: { "X-API-TOKEN" => "abc" } + + assert_equal "abc", connection.token + assert_equal "456", connection.current_user_id + end + + def test_connected_when_no_signed_cookies_set + cookies["user_id"] = "456" + + assert_reject_connection { connect } + end + + def test_connection_rejected + assert_reject_connection { connect } + end + + def test_connection_rejected_assertion_message + error = assert_raises Minitest::Assertion do + assert_reject_connection { "Intentionally doesn't connect." } + end + + assert_match(/Expected to reject connection/, error.message) + end +end + +class EncryptedCookiesConnection < ActionCable::Connection::Base + identified_by :user_id + + def connect + self.user_id = verify_user + end + + private + def verify_user + cookies.encrypted[:user_id].presence || reject_unauthorized_connection + end +end + +class EncryptedCookiesConnectionTest < ActionCable::Connection::TestCase + tests EncryptedCookiesConnection + + def test_connected_with_encrypted_cookies + cookies.encrypted["user_id"] = "456" + + connect + + assert_equal "456", connection.user_id + end + + def test_connected_with_encrypted_cookies_with_explicit_value_and_symbol_key + cookies.encrypted["user_id"] = { value: "456" } + + connect + + assert_equal "456", connection.user_id + end + + def test_connection_rejected + assert_reject_connection { connect } + end +end + +class SessionConnection < ActionCable::Connection::Base + identified_by :user_id + + def connect + self.user_id = verify_user + end + + private + def verify_user + request.session[:user_id].presence || reject_unauthorized_connection + end +end + +class SessionConnectionTest < ActionCable::Connection::TestCase + tests SessionConnection + + def test_connected_with_encrypted_cookies + connect session: { user_id: "789" } + assert_equal "789", connection.user_id + end + + def test_connection_rejected + assert_reject_connection { connect } + end +end + +class EnvConnection < ActionCable::Connection::Base + identified_by :user + + def connect + self.user = verify_user + end + + private + def verify_user + # Warden-like authentication + env["authenticator"]&.user || reject_unauthorized_connection + end +end + +class EnvConnectionTest < ActionCable::Connection::TestCase + tests EnvConnection + + def test_connected_with_env + authenticator = Class.new do + def user; "David"; end + end + + connect env: { "authenticator" => authenticator.new } + + assert_equal "David", connection.user + end + + def test_connection_rejected + assert_reject_connection { connect } + end +end diff --git a/actioncable/test/javascript/src/test.js b/actioncable/test/javascript/src/test.js new file mode 100644 index 0000000000000..938f71a2fa3a2 --- /dev/null +++ b/actioncable/test/javascript/src/test.js @@ -0,0 +1,8 @@ +import "./test_helpers/index" +import "./unit/action_cable_test" +import "./unit/connection_test" +import "./unit/connection_monitor_test" +import "./unit/consumer_test" +import "./unit/subscription_test" +import "./unit/subscriptions_test" +import "./unit/subscription_guarantor_test" diff --git a/actioncable/test/javascript/src/test_helpers/consumer_test_helper.js b/actioncable/test/javascript/src/test_helpers/consumer_test_helper.js new file mode 100644 index 0000000000000..30e8c277bc999 --- /dev/null +++ b/actioncable/test/javascript/src/test_helpers/consumer_test_helper.js @@ -0,0 +1,62 @@ +import { WebSocket as MockWebSocket, Server as MockServer } from "mock-socket" +import * as ActionCable from "../../../../app/javascript/action_cable/index" +import {defer, testURL} from "./index" + +export default function(name, options, callback) { + if (options == null) { options = {} } + if (callback == null) { + callback = options + options = {} + } + + if (options.url == null) { options.url = testURL } + + return QUnit.test(name, function(assert) { + const doneAsync = assert.async() + + ActionCable.adapters.WebSocket = MockWebSocket + const server = new MockServer(options.url) + const consumer = ActionCable.createConsumer(options.url) + const connection = consumer.connection + const monitor = connection.monitor + + if ("subprotocols" in options) consumer.addSubProtocol(options.subprotocols) + + server.on("connection", function() { + const clients = server.clients() + assert.equal(clients.length, 1) + assert.equal(clients[0].readyState, WebSocket.OPEN) + }) + + server.broadcastTo = function(subscription, data, callback) { + if (data == null) { data = {} } + data.identifier = subscription.identifier + + if (data.message_type) { + data.type = ActionCable.INTERNAL.message_types[data.message_type] + delete data.message_type + } + + server.send(JSON.stringify(data)) + defer(callback) + } + + const done = function() { + consumer.disconnect() + server.close() + doneAsync() + } + + const testData = {assert, consumer, connection, monitor, server, done} + + if (options.connect === false) { + callback(testData) + } else { + server.on("connection", function() { + testData.client = server.clients()[0] + callback(testData) + }) + consumer.connect() + } + }) +} diff --git a/actioncable/test/javascript/src/test_helpers/index.js b/actioncable/test/javascript/src/test_helpers/index.js new file mode 100644 index 0000000000000..0cd4e260b31e8 --- /dev/null +++ b/actioncable/test/javascript/src/test_helpers/index.js @@ -0,0 +1,10 @@ +import * as ActionCable from "../../../../app/javascript/action_cable/index" + +export const testURL = "ws://cable.example.com/" + +export function defer(callback) { + setTimeout(callback, 1) +} + +const originalWebSocket = ActionCable.adapters.WebSocket +QUnit.testDone(() => ActionCable.adapters.WebSocket = originalWebSocket) diff --git a/actioncable/test/javascript/src/unit/action_cable_test.js b/actioncable/test/javascript/src/unit/action_cable_test.js new file mode 100644 index 0000000000000..017959ab8ca80 --- /dev/null +++ b/actioncable/test/javascript/src/unit/action_cable_test.js @@ -0,0 +1,57 @@ +import * as ActionCable from "../../../../app/javascript/action_cable/index" +import {testURL} from "../test_helpers/index" + +const {module, test} = QUnit + +module("ActionCable", () => { + module("Adapters", () => { + module("WebSocket", () => { + test("default is WebSocket", assert => { + assert.equal(ActionCable.adapters.WebSocket, self.WebSocket) + }) + }) + + module("logger", () => { + test("default is console", assert => { + assert.equal(ActionCable.adapters.logger, self.console) + }) + }) + }) + + module("#createConsumer", () => { + test("uses specified URL", assert => { + const consumer = ActionCable.createConsumer(testURL) + assert.equal(consumer.url, testURL) + }) + + test("uses default URL", assert => { + const pattern = new RegExp(`${ActionCable.INTERNAL.default_mount_path}$`) + const consumer = ActionCable.createConsumer() + assert.ok(pattern.test(consumer.url), `Expected ${consumer.url} to match ${pattern}`) + }) + + test("uses URL from meta tag", assert => { + const element = document.createElement("meta") + element.setAttribute("name", "action-cable-url") + element.setAttribute("content", testURL) + + document.head.appendChild(element) + const consumer = ActionCable.createConsumer() + document.head.removeChild(element) + + assert.equal(consumer.url, testURL) + }) + + test("dynamically computes URL from function", assert => { + let dynamicURL = testURL + const generateURL = () => { + return dynamicURL + } + const consumer = ActionCable.createConsumer(generateURL) + assert.equal(consumer.url, testURL) + + dynamicURL = `${testURL}foo` + assert.equal(consumer.url, `${testURL}foo`) + }) + }) +}) diff --git a/actioncable/test/javascript/src/unit/connection_monitor_test.js b/actioncable/test/javascript/src/unit/connection_monitor_test.js new file mode 100644 index 0000000000000..ac5d92494ec99 --- /dev/null +++ b/actioncable/test/javascript/src/unit/connection_monitor_test.js @@ -0,0 +1,68 @@ +import * as ActionCable from "../../../../app/javascript/action_cable/index" + +const {module, test} = QUnit + +module("ActionCable.ConnectionMonitor", hooks => { + let monitor + hooks.beforeEach(() => monitor = new ActionCable.ConnectionMonitor({})) + + module("#getPollInterval", hooks => { + hooks.beforeEach(() => Math._random = Math.random) + hooks.afterEach(() => Math.random = Math._random) + + const { staleThreshold, reconnectionBackoffRate } = ActionCable.ConnectionMonitor + const backoffFactor = 1 + reconnectionBackoffRate + const ms = 1000 + + test("uses exponential backoff", assert => { + Math.random = () => 0 + + monitor.reconnectAttempts = 0 + assert.equal(monitor.getPollInterval(), staleThreshold * ms) + + monitor.reconnectAttempts = 1 + assert.equal(monitor.getPollInterval(), staleThreshold * backoffFactor * ms) + + monitor.reconnectAttempts = 2 + assert.equal(monitor.getPollInterval(), staleThreshold * backoffFactor * backoffFactor * ms) + }) + + test("caps exponential backoff after some number of reconnection attempts", assert => { + Math.random = () => 0 + monitor.reconnectAttempts = 42 + const cappedPollInterval = monitor.getPollInterval() + + monitor.reconnectAttempts = 9001 + assert.equal(monitor.getPollInterval(), cappedPollInterval) + }) + + test("uses 100% jitter when 0 reconnection attempts", assert => { + Math.random = () => 0 + assert.equal(monitor.getPollInterval(), staleThreshold * ms) + + Math.random = () => 0.5 + assert.equal(monitor.getPollInterval(), staleThreshold * 1.5 * ms) + }) + + test("uses reconnectionBackoffRate for jitter when >0 reconnection attempts", assert => { + monitor.reconnectAttempts = 1 + + Math.random = () => 0.25 + assert.equal(monitor.getPollInterval(), staleThreshold * backoffFactor * (1 + reconnectionBackoffRate * 0.25) * ms) + + Math.random = () => 0.5 + assert.equal(monitor.getPollInterval(), staleThreshold * backoffFactor * (1 + reconnectionBackoffRate * 0.5) * ms) + }) + + test("applies jitter after capped exponential backoff", assert => { + monitor.reconnectAttempts = 9001 + + Math.random = () => 0 + const withoutJitter = monitor.getPollInterval() + Math.random = () => 0.5 + const withJitter = monitor.getPollInterval() + + assert.ok(withJitter > withoutJitter) + }) + }) +}) diff --git a/actioncable/test/javascript/src/unit/connection_test.js b/actioncable/test/javascript/src/unit/connection_test.js new file mode 100644 index 0000000000000..9b1a975bfb63e --- /dev/null +++ b/actioncable/test/javascript/src/unit/connection_test.js @@ -0,0 +1,28 @@ +import * as ActionCable from "../../../../app/javascript/action_cable/index" + +const {module, test} = QUnit + +module("ActionCable.Connection", () => { + module("#getState", () => { + test("uses the configured WebSocket adapter", assert => { + ActionCable.adapters.WebSocket = { foo: 1, BAR: "42" } + const connection = new ActionCable.Connection({}) + connection.webSocket = {} + connection.webSocket.readyState = 1 + assert.equal(connection.getState(), "foo") + connection.webSocket.readyState = "42" + assert.equal(connection.getState(), "bar") + }) + }) + + module("#open", () => { + test("uses the configured WebSocket adapter", assert => { + const FakeWebSocket = function() {} + ActionCable.adapters.WebSocket = FakeWebSocket + const connection = new ActionCable.Connection({}) + connection.monitor = { start() {} } + connection.open() + assert.equal(connection.webSocket instanceof FakeWebSocket, true) + }) + }) +}) diff --git a/actioncable/test/javascript/src/unit/consumer_test.js b/actioncable/test/javascript/src/unit/consumer_test.js new file mode 100644 index 0000000000000..1eba5bbb4471f --- /dev/null +++ b/actioncable/test/javascript/src/unit/consumer_test.js @@ -0,0 +1,27 @@ +import consumerTest from "../test_helpers/consumer_test_helper" + +const {module} = QUnit + +module("ActionCable.Consumer", () => { + consumerTest("#connect", {connect: false}, ({consumer, server, assert, done}) => { + server.on("connection", () => { + assert.equal(consumer.connect(), false) + done() + }) + + consumer.connect() + }) + + consumerTest("#disconnect", ({consumer, client, done}) => { + client.addEventListener("close", done) + consumer.disconnect() + }) + + consumerTest("#addSubProtocol", {subprotocols: "some subprotocol"}, ({consumer, server, assert, done}) => { + server.on("connection", () => { + assert.equal(consumer.subprotocols.length, 1) + assert.equal(consumer.subprotocols[0], "some subprotocol") + done() + }) + }) +}) diff --git a/actioncable/test/javascript/src/unit/subscription_guarantor_test.js b/actioncable/test/javascript/src/unit/subscription_guarantor_test.js new file mode 100644 index 0000000000000..83665344f5d67 --- /dev/null +++ b/actioncable/test/javascript/src/unit/subscription_guarantor_test.js @@ -0,0 +1,32 @@ +import * as ActionCable from "../../../../app/javascript/action_cable/index" + +const {module, test} = QUnit + +module("ActionCable.SubscriptionGuarantor", hooks => { + let guarantor + hooks.beforeEach(() => guarantor = new ActionCable.SubscriptionGuarantor({})) + + module("#guarantee", () => { + test("guarantees subscription only once", assert => { + const sub = {} + + assert.equal(guarantor.pendingSubscriptions.length, 0) + guarantor.guarantee(sub) + assert.equal(guarantor.pendingSubscriptions.length, 1) + guarantor.guarantee(sub) + assert.equal(guarantor.pendingSubscriptions.length, 1) + }) + }), + + module("#forget", () => { + test("removes subscription", assert => { + const sub = {} + + assert.equal(guarantor.pendingSubscriptions.length, 0) + guarantor.guarantee(sub) + assert.equal(guarantor.pendingSubscriptions.length, 1) + guarantor.forget(sub) + assert.equal(guarantor.pendingSubscriptions.length, 0) + }) + }) +}) diff --git a/actioncable/test/javascript/src/unit/subscription_test.js b/actioncable/test/javascript/src/unit/subscription_test.js new file mode 100644 index 0000000000000..f1c2efabad33e --- /dev/null +++ b/actioncable/test/javascript/src/unit/subscription_test.js @@ -0,0 +1,68 @@ +import consumerTest from "../test_helpers/consumer_test_helper" + +const {module} = QUnit + +module("ActionCable.Subscription", () => { + consumerTest("#initialized callback", ({server, consumer, assert, done}) => + consumer.subscriptions.create("chat", { + initialized() { + assert.ok(true) + done() + } + }) + ) + + consumerTest("#connected callback", ({server, consumer, assert, done}) => { + const subscription = consumer.subscriptions.create("chat", { + connected({reconnected}) { + assert.ok(true) + assert.notOk(reconnected) + done() + } + }) + + server.broadcastTo(subscription, {message_type: "confirmation"}) + }) + + consumerTest("#connected callback (handling reconnects)", ({server, consumer, connection, monitor, assert, done}) => { + const subscription = consumer.subscriptions.create("chat", { + connected({reconnected}) { + assert.ok(reconnected) + done() + } + }) + + monitor.reconnectAttempts = 1 + server.broadcastTo(subscription, {message_type: "welcome"}) + server.broadcastTo(subscription, {message_type: "confirmation"}) + }) + + consumerTest("#disconnected callback", ({server, consumer, assert, done}) => { + const subscription = consumer.subscriptions.create("chat", { + disconnected() { + assert.ok(true) + done() + } + }) + + server.broadcastTo(subscription, {message_type: "confirmation"}, () => server.close()) + }) + + consumerTest("#perform", ({consumer, server, assert, done}) => { + const subscription = consumer.subscriptions.create("chat", { + connected() { + this.perform({publish: "hi"}) + } + }) + + server.on("message", (message) => { + const data = JSON.parse(message) + assert.equal(data.identifier, subscription.identifier) + assert.equal(data.command, "message") + assert.deepEqual(data.data, JSON.stringify({action: { publish: "hi" }})) + done() + }) + + server.broadcastTo(subscription, {message_type: "confirmation"}) + }) +}) diff --git a/actioncable/test/javascript/src/unit/subscriptions_test.js b/actioncable/test/javascript/src/unit/subscriptions_test.js new file mode 100644 index 0000000000000..33af5d4d824bf --- /dev/null +++ b/actioncable/test/javascript/src/unit/subscriptions_test.js @@ -0,0 +1,31 @@ +import consumerTest from "../test_helpers/consumer_test_helper" + +const {module} = QUnit + +module("ActionCable.Subscriptions", () => { + consumerTest("create subscription with channel string", ({consumer, server, assert, done}) => { + const channel = "chat" + + server.on("message", (message) => { + const data = JSON.parse(message) + assert.equal(data.command, "subscribe") + assert.equal(data.identifier, JSON.stringify({channel})) + done() + }) + + consumer.subscriptions.create(channel) + }) + + consumerTest("create subscription with channel object", ({consumer, server, assert, done}) => { + const channel = {channel: "chat", room: "action"} + + server.on("message", (message) => { + const data = JSON.parse(message) + assert.equal(data.command, "subscribe") + assert.equal(data.identifier, JSON.stringify(channel)) + done() + }) + + consumer.subscriptions.create(channel) + }) +}) diff --git a/actioncable/test/javascript_package_test.rb b/actioncable/test/javascript_package_test.rb new file mode 100644 index 0000000000000..948f98e68698f --- /dev/null +++ b/actioncable/test/javascript_package_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "test_helper" + +class JavascriptPackageTest < ActiveSupport::TestCase + def test_compiled_code_is_in_sync_with_source_code + compiled_files = %w[ + app/assets/javascripts/actioncable.js + app/assets/javascripts/actioncable.esm.js + app/assets/javascripts/action_cable.js + ].map do |file| + Pathname(file).expand_path("#{__dir__}/..") + end + + assert_no_changes -> { compiled_files.map(&:read) } do + system "yarn build", exception: true + end + end +end diff --git a/actioncable/test/server/base_test.rb b/actioncable/test/server/base_test.rb new file mode 100644 index 0000000000000..d46debea45a8d --- /dev/null +++ b/actioncable/test/server/base_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" +require "active_support/core_ext/hash/indifferent_access" + +class BaseTest < ActionCable::TestCase + def setup + @server = ActionCable::Server::Base.new + @server.config.cable = { adapter: "async" }.with_indifferent_access + end + + class FakeConnection + def close + end + end + + test "#restart closes all open connections" do + conn = FakeConnection.new + @server.add_connection(conn) + + assert_called(conn, :close) do + @server.restart + end + end + + test "#restart shuts down worker pool" do + assert_called(@server.worker_pool, :halt) do + @server.restart + end + end + + test "#restart shuts down pub/sub adapter" do + assert_called(@server.pubsub, :shutdown) do + @server.restart + end + end +end diff --git a/actioncable/test/server/broadcasting_test.rb b/actioncable/test/server/broadcasting_test.rb new file mode 100644 index 0000000000000..9caa9db477dd5 --- /dev/null +++ b/actioncable/test/server/broadcasting_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class BroadcastingTest < ActionCable::TestCase + setup do + @server = TestServer.new + @broadcasting = "test_queue" + @broadcaster = server.broadcaster_for(@broadcasting) + end + + attr_reader :server, :broadcasting, :broadcaster + + test "fetching a broadcaster converts the broadcasting queue to a string" do + assert_equal "test_queue", broadcaster.broadcasting + end + + test "broadcast generates notification" do + message = { body: "test message" } + expected_payload = { broadcasting:, message:, coder: ActiveSupport::JSON } + + assert_notifications_count("broadcast.action_cable", 1) do + assert_notification("broadcast.action_cable", expected_payload) do + server.broadcast(broadcasting, message) + end + end + end + + test "broadcaster from broadcaster_for generates notification" do + message = { body: "test message" } + expected_payload = { broadcasting:, message:, coder: ActiveSupport::JSON } + + assert_notifications_count("broadcast.action_cable", 1) do + assert_notification("broadcast.action_cable", expected_payload) do + broadcaster.broadcast(message) + end + end + end +end diff --git a/actioncable/test/server/health_check_test.rb b/actioncable/test/server/health_check_test.rb new file mode 100644 index 0000000000000..4fa3a108fb192 --- /dev/null +++ b/actioncable/test/server/health_check_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "test_helper" +require "active_support/core_ext/hash/indifferent_access" + +class HealthCheckTest < ActionCable::TestCase + def setup + @config = ActionCable::Server::Configuration.new + @config.logger = Logger.new(nil) + @server = ActionCable::Server::Base.new config: @config + @server.config.cable = { adapter: "async" }.with_indifferent_access + + @app = Rack::Lint.new(@server) + end + + + test "no health check app are mounted by default" do + get "/up" + assert_equal 404, response.first + end + + test "setting health_check_path mount the configured health check application" do + @server.config.health_check_path = "/up" + get "/up" + + assert_equal 200, response.first + assert_equal [], response.last.enum_for.to_a + end + + test "health_check_application_can_be_customized" do + @server.config.health_check_path = "/up" + @server.config.health_check_application = health_check_application + get "/up" + + assert_equal 200, response.first + assert_equal ["Hello world!"], response.last.enum_for.to_a + end + + + private + def get(path) + env = Rack::MockRequest.env_for "/up", "HTTP_HOST" => "localhost" + @response = @app.call env + end + + attr_reader :response + + def health_check_application + ->(env) { + [ + 200, + { Rack::CONTENT_TYPE => "text/html" }, + ["Hello world!"], + ] + } + end +end diff --git a/actioncable/test/stubs/global_id.rb b/actioncable/test/stubs/global_id.rb new file mode 100644 index 0000000000000..15fab6b8a7237 --- /dev/null +++ b/actioncable/test/stubs/global_id.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class GlobalID + attr_reader :uri + delegate :to_param, :to_s, to: :uri + + def initialize(gid, options = {}) + @uri = gid + end +end diff --git a/actioncable/test/stubs/room.rb b/actioncable/test/stubs/room.rb new file mode 100644 index 0000000000000..df7236f408909 --- /dev/null +++ b/actioncable/test/stubs/room.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Room + attr_reader :id, :name + + def initialize(id, name = "Campfire") + @id = id + @name = name + end + + def to_global_id + GlobalID.new("Room##{id}-#{name}") + end + + def to_gid_param + to_global_id.to_param + end +end diff --git a/actioncable/test/stubs/test_adapter.rb b/actioncable/test/stubs/test_adapter.rb new file mode 100644 index 0000000000000..22822acdffaa5 --- /dev/null +++ b/actioncable/test/stubs/test_adapter.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class SuccessAdapter < ActionCable::SubscriptionAdapter::Base + def broadcast(channel, payload) + end + + def subscribe(channel, callback, success_callback = nil) + subscriber_map[channel] << callback + @@subscribe_called = { channel: channel, callback: callback, success_callback: success_callback } + end + + def unsubscribe(channel, callback) + subscriber_map[channel].delete(callback) + subscriber_map.delete(channel) if subscriber_map[channel].empty? + @@unsubscribe_called = { channel: channel, callback: callback } + end + + def subscriber_map + @subscribers ||= Hash.new { |h, k| h[k] = [] } + end +end diff --git a/actioncable/test/stubs/test_connection.rb b/actioncable/test/stubs/test_connection.rb new file mode 100644 index 0000000000000..ca026516bb9f3 --- /dev/null +++ b/actioncable/test/stubs/test_connection.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "stubs/user" + +class TestConnection + attr_reader :identifiers, :logger, :current_user, :server, :subscriptions, :transmissions + + delegate :pubsub, :config, to: :server + + def initialize(user = User.new("lifo"), coder: ActiveSupport::JSON, subscription_adapter: SuccessAdapter) + @coder = coder + @identifiers = [ :current_user ] + + @current_user = user + @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + @server = TestServer.new(subscription_adapter: subscription_adapter) + @transmissions = [] + end + + def transmit(cable_message) + @transmissions << encode(cable_message) + end + + def last_transmission + decode @transmissions.last if @transmissions.any? + end + + def decode(websocket_message) + @coder.decode websocket_message + end + + def encode(cable_message) + @coder.encode cable_message + end +end diff --git a/actioncable/test/stubs/test_server.rb b/actioncable/test/stubs/test_server.rb new file mode 100644 index 0000000000000..2bdf0a28bed91 --- /dev/null +++ b/actioncable/test/stubs/test_server.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class TestServer + include ActionCable::Server::Connections + include ActionCable::Server::Broadcasting + + attr_reader :logger, :config, :mutex + + class FakeConfiguration < ActionCable::Server::Configuration + attr_accessor :subscription_adapter, :log_tags, :filter_parameters + + def initialize(subscription_adapter:) + @log_tags = [] + @filter_parameters = [] + @subscription_adapter = subscription_adapter + end + + def pubsub_adapter + @subscription_adapter + end + end + + def initialize(subscription_adapter: SuccessAdapter) + @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + @config = FakeConfiguration.new(subscription_adapter: subscription_adapter) + @mutex = Monitor.new + end + + def pubsub + @pubsub ||= @config.subscription_adapter.new(self) + end + + def event_loop + @event_loop ||= ActionCable::Connection::StreamEventLoop.new.tap do |loop| + loop.instance_variable_set(:@executor, Concurrent.global_io_executor) + end + end + + def worker_pool + @worker_pool ||= ActionCable::Server::Worker.new(max_size: 5) + end +end diff --git a/actioncable/test/stubs/user.rb b/actioncable/test/stubs/user.rb new file mode 100644 index 0000000000000..7894d1d9ae566 --- /dev/null +++ b/actioncable/test/stubs/user.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class User + attr_reader :name + + def initialize(name) + @name = name + end + + def to_global_id + GlobalID.new("User##{name}") + end + + def to_gid_param + to_global_id.to_param + end +end diff --git a/actioncable/test/subscription_adapter/async_test.rb b/actioncable/test/subscription_adapter/async_test.rb new file mode 100644 index 0000000000000..6e038259b55ce --- /dev/null +++ b/actioncable/test/subscription_adapter/async_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "common" + +class AsyncAdapterTest < ActionCable::TestCase + include CommonSubscriptionAdapterTest + + def setup + super + + @tx_adapter.shutdown + @tx_adapter = @rx_adapter + end + + def cable_config + { adapter: "async" } + end +end diff --git a/actioncable/test/subscription_adapter/base_test.rb b/actioncable/test/subscription_adapter/base_test.rb new file mode 100644 index 0000000000000..999dc0cba1069 --- /dev/null +++ b/actioncable/test/subscription_adapter/base_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "test_helper" +require "stubs/test_server" + +class ActionCable::SubscriptionAdapter::BaseTest < ActionCable::TestCase + ## TEST THAT ERRORS ARE RETURNED FOR INHERITORS THAT DON'T OVERRIDE METHODS + + class BrokenAdapter < ActionCable::SubscriptionAdapter::Base + end + + setup do + @server = TestServer.new + @server.config.subscription_adapter = BrokenAdapter + @server.config.allowed_request_origins = %w( http://rubyonrails.com ) + end + + test "#broadcast returns NotImplementedError by default" do + assert_raises NotImplementedError do + BrokenAdapter.new(@server).broadcast("channel", "payload") + end + end + + test "#subscribe returns NotImplementedError by default" do + callback = lambda { puts "callback" } + success_callback = lambda { puts "success" } + + assert_raises NotImplementedError do + BrokenAdapter.new(@server).subscribe("channel", callback, success_callback) + end + end + + test "#unsubscribe returns NotImplementedError by default" do + callback = lambda { puts "callback" } + + assert_raises NotImplementedError do + BrokenAdapter.new(@server).unsubscribe("channel", callback) + end + end + + # TEST METHODS THAT ARE REQUIRED OF THE ADAPTER'S BACKEND STORAGE OBJECT + + test "#broadcast is implemented" do + assert_nothing_raised do + SuccessAdapter.new(@server).broadcast("channel", "payload") + end + end + + test "#subscribe is implemented" do + callback = lambda { puts "callback" } + success_callback = lambda { puts "success" } + + assert_nothing_raised do + SuccessAdapter.new(@server).subscribe("channel", callback, success_callback) + end + end + + test "#unsubscribe is implemented" do + callback = lambda { puts "callback" } + + assert_nothing_raised do + SuccessAdapter.new(@server).unsubscribe("channel", callback) + end + end +end diff --git a/actioncable/test/subscription_adapter/channel_prefix.rb b/actioncable/test/subscription_adapter/channel_prefix.rb new file mode 100644 index 0000000000000..16baa4e2d6c3a --- /dev/null +++ b/actioncable/test/subscription_adapter/channel_prefix.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "test_helper" + +module ChannelPrefixTest + def test_channel_prefix + server2 = ActionCable::Server::Base.new(config: ActionCable::Server::Configuration.new) + server2.config.cable = alt_cable_config.with_indifferent_access + server2.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + + adapter_klass = server2.config.pubsub_adapter + + rx_adapter2 = adapter_klass.new(server2) + tx_adapter2 = adapter_klass.new(server2) + + subscribe_as_queue("channel") do |queue| + subscribe_as_queue("channel", rx_adapter2) do |queue2| + @tx_adapter.broadcast("channel", "hello world") + tx_adapter2.broadcast("channel", "hello world 2") + + assert_equal "hello world", queue.pop + assert_equal "hello world 2", queue2.pop + end + end + end + + def alt_cable_config + cable_config.merge(channel_prefix: "foo") + end +end diff --git a/actioncable/test/subscription_adapter/common.rb b/actioncable/test/subscription_adapter/common.rb new file mode 100644 index 0000000000000..b3e9ae9d5ceca --- /dev/null +++ b/actioncable/test/subscription_adapter/common.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "test_helper" +require "concurrent" + +require "active_support/core_ext/hash/indifferent_access" +require "pathname" + +module CommonSubscriptionAdapterTest + WAIT_WHEN_EXPECTING_EVENT = 3 + WAIT_WHEN_NOT_EXPECTING_EVENT = 0.2 + + def setup + server = ActionCable::Server::Base.new + server.config.cable = cable_config.with_indifferent_access + server.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + + adapter_klass = server.config.pubsub_adapter + + @rx_adapter = adapter_klass.new(server) + @tx_adapter = adapter_klass.new(server) + end + + def teardown + [@rx_adapter, @tx_adapter].uniq.compact.each(&:shutdown) + end + + def subscribe_as_queue(channel, adapter = @rx_adapter) + queue = Queue.new + + callback = -> data { queue << data } + subscribed = Concurrent::Event.new + adapter.subscribe(channel, callback, Proc.new { subscribed.set }) + subscribed.wait(WAIT_WHEN_EXPECTING_EVENT) + assert_predicate subscribed, :set? + + yield queue + + sleep WAIT_WHEN_NOT_EXPECTING_EVENT + assert_empty queue + ensure + adapter.unsubscribe(channel, callback) if subscribed.set? + end + + def test_subscribe_and_unsubscribe + subscribe_as_queue("channel") do |queue| + end + end + + def test_basic_broadcast + subscribe_as_queue("channel") do |queue| + @tx_adapter.broadcast("channel", "hello world") + + assert_equal "hello world", queue.pop + end + end + + def test_broadcast_after_unsubscribe + keep_queue = nil + subscribe_as_queue("channel") do |queue| + keep_queue = queue + + @tx_adapter.broadcast("channel", "hello world") + + assert_equal "hello world", queue.pop + end + + @tx_adapter.broadcast("channel", "hello void") + + sleep WAIT_WHEN_NOT_EXPECTING_EVENT + assert_empty keep_queue + end + + def test_multiple_broadcast + subscribe_as_queue("channel") do |queue| + @tx_adapter.broadcast("channel", "bananas") + @tx_adapter.broadcast("channel", "apples") + + received = [] + 2.times { received << queue.pop } + assert_equal ["apples", "bananas"], received.sort + end + end + + def test_identical_subscriptions + subscribe_as_queue("channel") do |queue| + subscribe_as_queue("channel") do |queue_2| + @tx_adapter.broadcast("channel", "hello") + + assert_equal "hello", queue_2.pop + end + + assert_equal "hello", queue.pop + end + end + + def test_simultaneous_subscriptions + subscribe_as_queue("channel") do |queue| + subscribe_as_queue("other channel") do |queue_2| + @tx_adapter.broadcast("channel", "apples") + @tx_adapter.broadcast("other channel", "oranges") + + assert_equal "apples", queue.pop + assert_equal "oranges", queue_2.pop + end + end + end + + def test_channel_filtered_broadcast + subscribe_as_queue("channel") do |queue| + @tx_adapter.broadcast("other channel", "one") + @tx_adapter.broadcast("channel", "two") + + assert_equal "two", queue.pop + end + end + + def test_long_identifiers + channel_1 = "a" * 100 + "1" + channel_2 = "a" * 100 + "2" + subscribe_as_queue(channel_1) do |queue| + subscribe_as_queue(channel_2) do |queue_2| + @tx_adapter.broadcast(channel_1, "apples") + @tx_adapter.broadcast(channel_2, "oranges") + + assert_equal "apples", queue.pop + assert_equal "oranges", queue_2.pop + end + end + end +end diff --git a/actioncable/test/subscription_adapter/inline_test.rb b/actioncable/test/subscription_adapter/inline_test.rb new file mode 100644 index 0000000000000..6305626b2bb39 --- /dev/null +++ b/actioncable/test/subscription_adapter/inline_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "common" + +class InlineAdapterTest < ActionCable::TestCase + include CommonSubscriptionAdapterTest + + def setup + super + + @tx_adapter.shutdown + @tx_adapter = @rx_adapter + end + + def cable_config + { adapter: "inline" } + end +end diff --git a/actioncable/test/subscription_adapter/postgresql_test.rb b/actioncable/test/subscription_adapter/postgresql_test.rb new file mode 100644 index 0000000000000..5dd091621e818 --- /dev/null +++ b/actioncable/test/subscription_adapter/postgresql_test.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "common" +require_relative "channel_prefix" + +require "active_record" + +class PostgresqlAdapterTest < ActionCable::TestCase + include CommonSubscriptionAdapterTest + include ChannelPrefixTest + + def setup + database_config = { "adapter" => "postgresql", "database" => "activerecord_unittest" } + ar_tests = File.expand_path("../../../activerecord/test", __dir__) + if Dir.exist?(ar_tests) + require File.join(ar_tests, "config") + require File.join(ar_tests, "support/config") + local_config = ARTest.config["connections"]["postgresql"]["arunit"] + database_config.update local_config if local_config + end + + ActiveRecord::Base.establish_connection database_config + + begin + ActiveRecord::Base.lease_connection.connect! + rescue + @rx_adapter = @tx_adapter = nil + skip "Couldn't connect to PostgreSQL: #{database_config.inspect}" + end + + super + end + + def teardown + super + + ActiveRecord::Base.connection_handler.clear_all_connections! + end + + def cable_config + { adapter: "postgresql" } + end + + def test_clear_active_record_connections_adapter_still_works + server = ActionCable::Server::Base.new + server.config.cable = cable_config.with_indifferent_access + server.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + + adapter_klass = Class.new(server.config.pubsub_adapter) do + def active? + !@listener.nil? + end + end + + adapter = adapter_klass.new(server) + + subscribe_as_queue("channel", adapter) do |queue| + adapter.broadcast("channel", "hello world") + assert_equal "hello world", queue.pop + end + + ActiveRecord::Base.connection_handler.clear_reloadable_connections! + + assert_predicate adapter, :active? + end + + def test_default_subscription_connection_identifier + subscribe_as_queue("channel") { } + + identifiers = ActiveRecord::Base.lease_connection.exec_query("SELECT application_name FROM pg_stat_activity").rows + assert_includes identifiers, ["ActionCable-PID-#{$$}"] + end + + def test_custom_subscription_connection_identifier + server = ActionCable::Server::Base.new + server.config.cable = cable_config.merge(id: "hello-world-42").with_indifferent_access + server.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + + adapter = server.config.pubsub_adapter.new(server) + + subscribe_as_queue("channel", adapter) { } + + identifiers = ActiveRecord::Base.lease_connection.exec_query("SELECT application_name FROM pg_stat_activity").rows + assert_includes identifiers, ["hello-world-42"] + end +end diff --git a/actioncable/test/subscription_adapter/redis_test.rb b/actioncable/test/subscription_adapter/redis_test.rb new file mode 100644 index 0000000000000..e90e4129ce89e --- /dev/null +++ b/actioncable/test/subscription_adapter/redis_test.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "common" +require_relative "channel_prefix" + +class RedisAdapterTest < ActionCable::TestCase + include CommonSubscriptionAdapterTest + include ChannelPrefixTest + + def cable_config + { adapter: "redis", driver: "ruby" }.tap do |x| + if host = ENV["REDIS_URL"] + x[:url] = host + end + end + end + + def test_reconnections + subscribe_as_queue("channel") do |queue| + subscribe_as_queue("other channel") do |queue_2| + @tx_adapter.broadcast("channel", "hello world") + + assert_equal "hello world", queue.pop + + drop_pubsub_connections + wait_pubsub_connection(redis_conn, "channel") + + @tx_adapter.broadcast("channel", "hallo welt") + + assert_equal "hallo welt", queue.pop + + drop_pubsub_connections + wait_pubsub_connection(redis_conn, "channel") + wait_pubsub_connection(redis_conn, "other channel") + + @tx_adapter.broadcast("channel", "hola mundo") + @tx_adapter.broadcast("other channel", "other message") + + assert_equal "hola mundo", queue.pop + assert_equal "other message", queue_2.pop + end + end + end + + private + def redis_conn + @redis_conn ||= ::Redis.new(cable_config.except(:adapter)) + end + + def drop_pubsub_connections + # Emulate connection failure by dropping all connections + redis_conn.client("kill", "type", "pubsub") + end + + def wait_pubsub_connection(redis_conn, channel, timeout: 5) + wait = timeout + loop do + break if redis_conn.pubsub("numsub", channel).last > 0 + + sleep 0.1 + wait -= 0.1 + + raise "Timed out to subscribe to #{channel}" if wait <= 0 + end + end +end + +class RedisAdapterTest::AlternateConfiguration < RedisAdapterTest + def cable_config + alt_cable_config = super.dup + alt_cable_config.delete(:url) + url = URI(ENV["REDIS_URL"] || "") + alt_cable_config.merge(host: url.hostname || "127.0.0.1", port: url.port || 6379, db: 12) + end +end + +class RedisAdapterTest::ConnectorDefaultID < ActionCable::TestCase + def setup + server = ActionCable::Server::Base.new + server.config.cable = cable_config.merge(adapter: "redis").with_indifferent_access + server.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + + @adapter = server.config.pubsub_adapter.new(server) + end + + def cable_config + { url: 1, host: 2, port: 3, db: 4, password: 5 } + end + + def connection_id + "ActionCable-PID-#{$$}" + end + + def expected_connection + cable_config.merge(id: connection_id) + end + + test "sets connection id for connection" do + assert_called_with ::Redis, :new, [ expected_connection.symbolize_keys ] do + @adapter.send(:redis_connection) + end + end +end + +class RedisAdapterTest::ConnectorCustomID < RedisAdapterTest::ConnectorDefaultID + def cable_config + super.merge(id: connection_id) + end + + def connection_id + "Some custom ID" + end +end + +class RedisAdapterTest::ConnectorCustomIDNil < RedisAdapterTest::ConnectorDefaultID + def cable_config + super.merge(id: connection_id) + end + + def connection_id + nil + end +end + +class RedisAdapterTest::ConnectorWithExcluded < RedisAdapterTest::ConnectorDefaultID + def cable_config + super.merge(adapter: "redis", channel_prefix: "custom") + end + + def expected_connection + super.except(:adapter, :channel_prefix) + end +end + +class RedisAdapterTest::SentinelConfigAsHash < ActionCable::TestCase + def setup + server = ActionCable::Server::Base.new + server.config.cable = cable_config.merge(adapter: "redis").with_indifferent_access + server.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + + @adapter = server.config.pubsub_adapter.new(server) + end + + def cable_config + { url: "redis://test", sentinels: [{ "host" => "localhost", "port" => 26379 }] } + end + + def expected_connection + { url: "redis://test", sentinels: [{ host: "localhost", port: 26379 }], id: connection_id } + end + + def connection_id + "ActionCable-PID-#{$$}" + end + + test "sets sentinels as array of hashes with keyword arguments" do + assert_called_with ::Redis, :new, [ expected_connection ] do + @adapter.send(:redis_connection) + end + end +end diff --git a/actioncable/test/subscription_adapter/subscriber_map_test.rb b/actioncable/test/subscription_adapter/subscriber_map_test.rb new file mode 100644 index 0000000000000..ed81099cbc6e7 --- /dev/null +++ b/actioncable/test/subscription_adapter/subscriber_map_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "test_helper" + +class SubscriberMapTest < ActionCable::TestCase + test "broadcast should not change subscribers" do + setup_subscription_map + origin = @subscription_map.instance_variable_get(:@subscribers).dup + + @subscription_map.broadcast("not_exist_channel", "") + + assert_equal origin, @subscription_map.instance_variable_get(:@subscribers) + end + + private + def setup_subscription_map + @subscription_map = ActionCable::SubscriptionAdapter::SubscriberMap.new + end +end diff --git a/actioncable/test/subscription_adapter/test_adapter_test.rb b/actioncable/test/subscription_adapter/test_adapter_test.rb new file mode 100644 index 0000000000000..3fe07adb4ae35 --- /dev/null +++ b/actioncable/test/subscription_adapter/test_adapter_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "common" + +class ActionCable::SubscriptionAdapter::TestTest < ActionCable::TestCase + include CommonSubscriptionAdapterTest + + def setup + super + + @tx_adapter.shutdown + @tx_adapter = @rx_adapter + end + + def cable_config + { adapter: "test" } + end + + test "#broadcast stores messages for streams" do + @tx_adapter.broadcast("channel", "payload") + @tx_adapter.broadcast("channel2", "payload2") + + assert_equal ["payload"], @tx_adapter.broadcasts("channel") + assert_equal ["payload2"], @tx_adapter.broadcasts("channel2") + end + + test "#clear_messages deletes recorded broadcasts for the channel" do + @tx_adapter.broadcast("channel", "payload") + @tx_adapter.broadcast("channel2", "payload2") + + @tx_adapter.clear_messages("channel") + + assert_equal [], @tx_adapter.broadcasts("channel") + assert_equal ["payload2"], @tx_adapter.broadcasts("channel2") + end + + test "#clear deletes all recorded broadcasts" do + @tx_adapter.broadcast("channel", "payload") + @tx_adapter.broadcast("channel2", "payload2") + + @tx_adapter.clear + + assert_equal [], @tx_adapter.broadcasts("channel") + assert_equal [], @tx_adapter.broadcasts("channel2") + end +end diff --git a/actioncable/test/test_helper.rb b/actioncable/test/test_helper.rb new file mode 100644 index 0000000000000..d6305dcc766cf --- /dev/null +++ b/actioncable/test/test_helper.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "../../tools/strict_warnings" +require "action_cable" +require "active_support/testing/autorun" +require "active_support/testing/method_call_assertions" + +require "puma" +require "rack/mock" + +# Require all the stubs and models +Dir[File.expand_path("stubs/*.rb", __dir__)].each { |file| require file } + +# Set test adapter and logger +ActionCable.server.config.cable = { "adapter" => "test" } +ActionCable.server.config.logger = Logger.new(nil) + +class ActionCable::TestCase < ActiveSupport::TestCase + include ActiveSupport::Testing::MethodCallAssertions + + def wait_for_async + wait_for_executor Concurrent.global_io_executor + end + + def run_in_eventmachine + yield + wait_for_async + end + + def wait_for_executor(executor) + # do not wait forever, wait 2s + timeout = 2 + until executor.completed_task_count == executor.scheduled_task_count + sleep 0.1 + timeout -= 0.1 + raise "Executor could not complete all tasks in 2 seconds" unless timeout > 0 + end + end +end + +require_relative "../../tools/test_common" diff --git a/actioncable/test/test_helper_test.rb b/actioncable/test/test_helper_test.rb new file mode 100644 index 0000000000000..ef5615f4b0b84 --- /dev/null +++ b/actioncable/test/test_helper_test.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "test_helper" + +class BroadcastChannel < ActionCable::Channel::Base +end + +class TransmissionsTest < ActionCable::TestCase + def test_assert_broadcasts + assert_nothing_raised do + assert_broadcasts("test", 1) do + ActionCable.server.broadcast "test", "message" + end + end + end + + def test_capture_broadcasts + messages = capture_broadcasts("test") do + ActionCable.server.broadcast "test", "message" + end + assert_equal "message", messages.first + + messages = capture_broadcasts("test") do + ActionCable.server.broadcast "test", { message: "one" } + ActionCable.server.broadcast "test", { message: "two" } + end + assert_equal 2, messages.length + assert_equal({ "message" => "one" }, messages.first) + assert_equal({ "message" => "two" }, messages.last) + end + + def test_assert_broadcasts_with_no_block + assert_nothing_raised do + ActionCable.server.broadcast "test", "message" + assert_broadcasts "test", 1 + end + + assert_nothing_raised do + ActionCable.server.broadcast "test", "message 2" + ActionCable.server.broadcast "test", "message 3" + assert_broadcasts "test", 3 + end + end + + def test_assert_no_broadcasts_with_no_block + assert_nothing_raised do + assert_no_broadcasts "test" + end + end + + def test_assert_no_broadcasts + assert_nothing_raised do + assert_no_broadcasts("test") do + ActionCable.server.broadcast "test2", "message" + end + end + end + + def test_assert_broadcasts_message_too_few_sent + ActionCable.server.broadcast "test", "hello" + error = assert_raises Minitest::Assertion do + assert_broadcasts("test", 2) do + ActionCable.server.broadcast "test", "world" + end + end + + assert_match(/2 .* but 1/, error.message) + end + + def test_assert_broadcasts_message_too_many_sent + error = assert_raises Minitest::Assertion do + assert_broadcasts("test", 1) do + ActionCable.server.broadcast "test", "hello" + ActionCable.server.broadcast "test", "world" + end + end + + assert_match(/1 .* but 2/, error.message) + end + + def test_assert_no_broadcasts_failure + error = assert_raises Minitest::Assertion do + assert_no_broadcasts "test" do + ActionCable.server.broadcast "test", "hello" + end + end + + assert_match(/0 .* but 1/, error.message) + end +end + +class TransmittedDataTest < ActionCable::TestCase + include ActionCable::TestHelper + + def test_assert_broadcast_on + assert_nothing_raised do + assert_broadcast_on("test", "message") do + ActionCable.server.broadcast "test", "message" + end + end + end + + def test_assert_broadcast_on_with_hash + assert_nothing_raised do + assert_broadcast_on("test", text: "hello") do + ActionCable.server.broadcast "test", { text: "hello" } + end + end + end + + def test_assert_broadcast_on_with_no_block + assert_nothing_raised do + ActionCable.server.broadcast "test", "hello" + assert_broadcast_on "test", "hello" + end + + assert_nothing_raised do + ActionCable.server.broadcast "test", "world" + assert_broadcast_on "test", "world" + end + end + + def test_assert_broadcast_on_message + ActionCable.server.broadcast "test", "hello" + error = assert_raises Minitest::Assertion do + assert_broadcast_on("test", "world") + end + + assert_match(/No messages sent/, error.message) + assert_match(/Message\(s\) found:\nhello/, error.message) + end + + def test_assert_broadcast_on_message_with_empty_channel + error = assert_raises Minitest::Assertion do + assert_broadcast_on("test", "world") + end + + assert_match(/No messages sent/, error.message) + assert_match(/No message found for test/, error.message) + end +end diff --git a/actioncable/test/worker_test.rb b/actioncable/test/worker_test.rb new file mode 100644 index 0000000000000..f7dc428441ab6 --- /dev/null +++ b/actioncable/test/worker_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "test_helper" + +class WorkerTest < ActionCable::TestCase + class Receiver + attr_accessor :last_action + + def run + @last_action = :run + end + + def process(message) + @last_action = [ :process, message ] + end + + def connection + self + end + + def logger + # Impersonating a connection requires a TaggedLoggerProxy'ied logger. + inner_logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } + ActionCable::Connection::TaggedLoggerProxy.new(inner_logger, tags: []) + end + end + + setup do + @worker = ActionCable::Server::Worker.new + @receiver = Receiver.new + end + + teardown do + @receiver.last_action = nil + end + + test "invoke" do + @worker.invoke @receiver, :run, connection: @receiver.connection + assert_equal :run, @receiver.last_action + end + + test "invoke with arguments" do + @worker.invoke @receiver, :process, "Hello", connection: @receiver.connection + assert_equal [ :process, "Hello" ], @receiver.last_action + end +end diff --git a/actionmailbox/.gitignore b/actionmailbox/.gitignore new file mode 100644 index 0000000000000..d84c713a08039 --- /dev/null +++ b/actionmailbox/.gitignore @@ -0,0 +1,7 @@ +/test/dummy/storage/*.sqlite3 +/test/dummy/storage/*.sqlite3-* +/test/dummy/db/*.sqlite3 +/test/dummy/db/*.sqlite3-* +/test/dummy/log/*.log +/test/dummy/tmp/ +/tmp/ diff --git a/actionmailbox/CHANGELOG.md b/actionmailbox/CHANGELOG.md new file mode 100644 index 0000000000000..33398e8780b7f --- /dev/null +++ b/actionmailbox/CHANGELOG.md @@ -0,0 +1,5 @@ +* Add `reply_to_address` extension method on `Mail::Message`. + + *Mr0grog* + +Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actionmailbox/CHANGELOG.md) for previous changes. diff --git a/actionmailbox/MIT-LICENSE b/actionmailbox/MIT-LICENSE new file mode 100644 index 0000000000000..18b341857b9f9 --- /dev/null +++ b/actionmailbox/MIT-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 37signals LLC + +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. diff --git a/actionmailbox/README.md b/actionmailbox/README.md new file mode 100644 index 0000000000000..47fcbc0113fe1 --- /dev/null +++ b/actionmailbox/README.md @@ -0,0 +1,13 @@ +# Action Mailbox + +Action Mailbox routes incoming emails to controller-like mailboxes for processing in \Rails. It ships with ingresses for Mailgun, Mandrill, Postmark, and SendGrid. You can also handle inbound mails directly via the built-in Exim, Postfix, and Qmail ingresses. + +The inbound emails are turned into `InboundEmail` records using Active Record and feature lifecycle tracking, storage of the original email on cloud storage via Active Storage, and responsible data handling with on-by-default incineration. + +These inbound emails are routed asynchronously using Active Job to one or several dedicated mailboxes, which are capable of interacting directly with the rest of your domain model. + +You can read more about Action Mailbox in the [Action Mailbox Basics](https://guides.rubyonrails.org/action_mailbox_basics.html) guide. + +## License + +Action Mailbox is released under the [MIT License](https://opensource.org/licenses/MIT). diff --git a/actionmailbox/Rakefile b/actionmailbox/Rakefile new file mode 100644 index 0000000000000..45e88e82e16d2 --- /dev/null +++ b/actionmailbox/Rakefile @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "bundler/gem_tasks" +require "rake/testtask" + +ENV["RAILS_MINITEST_PLUGIN"] = "true" + +Rake::TestTask.new do |t| + t.libs << "test" + t.pattern = "test/**/*_test.rb" + t.verbose = true + t.options = "--profile" if ENV["CI"] +end + +namespace :test do + task :isolated do + FileList["test/**/*_test.rb"].exclude("test/dummy/**/*").all? do |file| + sh(Gem.ruby, "-w", "-Ilib", "-Itest", file) + end || raise("Failures") + end +end + +task default: :test diff --git a/actionmailbox/actionmailbox.gemspec b/actionmailbox/actionmailbox.gemspec new file mode 100644 index 0000000000000..7496467f95f73 --- /dev/null +++ b/actionmailbox/actionmailbox.gemspec @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +version = File.read(File.expand_path("../RAILS_VERSION", __dir__)).strip + +Gem::Specification.new do |s| + s.platform = Gem::Platform::RUBY + s.name = "actionmailbox" + s.version = version + s.summary = "Inbound email handling framework." + s.description = "Receive and process incoming emails in Rails applications." + + s.required_ruby_version = ">= 3.2.0" + + s.license = "MIT" + + s.authors = ["David Heinemeier Hansson", "George Claghorn"] + s.email = ["david@loudthinking.com", "george@basecamp.com"] + s.homepage = "https://rubyonrails.org/" + + s.files = Dir["CHANGELOG.md", "MIT-LICENSE", "README.md", "lib/**/*", "app/**/*", "config/**/*", "db/**/*"] + s.require_path = "lib" + + s.metadata = { + "bug_tracker_uri" => "https://github.com/rails/rails/issues", + "changelog_uri" => "https://github.com/rails/rails/blob/v#{version}/actionmailbox/CHANGELOG.md", + "documentation_uri" => "https://api.rubyonrails.org/v#{version}/", + "mailing_list_uri" => "https://discuss.rubyonrails.org/c/rubyonrails-talk", + "source_code_uri" => "https://github.com/rails/rails/tree/v#{version}/actionmailbox", + "rubygems_mfa_required" => "true", + } + + # NOTE: Please read our dependency guidelines before updating versions: + # https://edgeguides.rubyonrails.org/security.html#dependency-management-and-cves + + s.add_dependency "activesupport", version + s.add_dependency "activerecord", version + s.add_dependency "activestorage", version + s.add_dependency "activejob", version + s.add_dependency "actionpack", version + + s.add_dependency "mail", ">= 2.8.0" +end diff --git a/actionmailbox/app/controllers/action_mailbox/base_controller.rb b/actionmailbox/app/controllers/action_mailbox/base_controller.rb new file mode 100644 index 0000000000000..fdd3b5e735aa8 --- /dev/null +++ b/actionmailbox/app/controllers/action_mailbox/base_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module ActionMailbox + # The base class for all Action Mailbox ingress controllers. + class BaseController < ActionController::Base + skip_forgery_protection + + before_action :ensure_configured + + private + def ensure_configured + unless ActionMailbox.ingress == ingress_name + head :not_found + end + end + + def ingress_name + self.class.name.remove(/\AActionMailbox::Ingresses::/, /::InboundEmailsController\z/).underscore.to_sym + end + + + def authenticate_by_password + if password.present? + http_basic_authenticate_or_request_with name: "actionmailbox", password: password, realm: "Action Mailbox" + else + raise ArgumentError, "Missing required ingress credentials" + end + end + + def password + Rails.application.credentials.dig(:action_mailbox, :ingress_password) || ENV["RAILS_INBOUND_EMAIL_PASSWORD"] + end + end +end diff --git a/actionmailbox/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb b/actionmailbox/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb new file mode 100644 index 0000000000000..c14e5334755e8 --- /dev/null +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/mailgun/inbound_emails_controller.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module ActionMailbox + # Ingests inbound emails from Mailgun. Requires the following parameters: + # + # - +body-mime+: The full RFC 822 message + # - +timestamp+: The current time according to Mailgun as the number of seconds passed since the UNIX epoch + # - +token+: A randomly-generated, 50-character string + # - +signature+: A hexadecimal HMAC-SHA256 of the timestamp concatenated with the token, generated using the Mailgun Signing key + # + # Authenticates requests by validating their signatures. + # + # Returns: + # + # - 204 No Content if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox + # - 401 Unauthorized if the request's signature could not be validated, or if its timestamp is more than 2 minutes old + # - 404 Not Found if Action Mailbox is not configured to accept inbound emails from Mailgun + # - 422 Unprocessable Entity if the request is missing required parameters + # - 500 Server Error if the Mailgun Signing key is missing, or one of the Active Record database, + # the Active Storage service, or the Active Job backend is misconfigured or unavailable + # + # == Usage + # + # 1. Give Action Mailbox your Mailgun Signing key (which you can find under Settings -> Security & Users -> API security in Mailgun) + # so it can authenticate requests to the Mailgun ingress. + # + # Use bin/rails credentials:edit to add your Signing key to your application's encrypted credentials under + # +action_mailbox.mailgun_signing_key+, where Action Mailbox will automatically find it: + # + # action_mailbox: + # mailgun_signing_key: ... + # + # Alternatively, provide your Signing key in the +MAILGUN_INGRESS_SIGNING_KEY+ environment variable. + # + # 2. Tell Action Mailbox to accept emails from Mailgun: + # + # # config/environments/production.rb + # config.action_mailbox.ingress = :mailgun + # + # 3. {Configure Mailgun}[https://documentation.mailgun.com/en/latest/user_manual.html#receiving-forwarding-and-storing-messages] + # to forward inbound emails to +/rails/action_mailbox/mailgun/inbound_emails/mime+. + # + # If your application lived at https://example.com, you would specify the fully-qualified URL + # https://example.com/rails/action_mailbox/mailgun/inbound_emails/mime. + class Ingresses::Mailgun::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate + param_encoding :create, "body-mime", Encoding::ASCII_8BIT + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! mail + end + + private + def mail + params.require("body-mime").tap do |raw_email| + raw_email.prepend("X-Original-To: ", params.require(:recipient), "\n") if params.key?(:recipient) + end + end + + def authenticate + head :unauthorized unless authenticated? + end + + def authenticated? + if key.present? + Authenticator.new( + key: key, + timestamp: params.require(:timestamp), + token: params.require(:token), + signature: params.require(:signature) + ).authenticated? + else + raise ArgumentError, <<~MESSAGE.squish + Missing required Mailgun Signing key. Set action_mailbox.mailgun_signing_key in your application's + encrypted credentials or provide the MAILGUN_INGRESS_SIGNING_KEY environment variable. + MESSAGE + end + end + + def key + Rails.application.credentials.dig(:action_mailbox, :mailgun_signing_key) || ENV["MAILGUN_INGRESS_SIGNING_KEY"] + end + + class Authenticator + attr_reader :key, :timestamp, :token, :signature + + def initialize(key:, timestamp:, token:, signature:) + @key, @timestamp, @token, @signature = key, Integer(timestamp), token, signature + end + + def authenticated? + signed? && recent? + end + + private + def signed? + ActiveSupport::SecurityUtils.secure_compare signature, expected_signature + end + + # Allow for 2 minutes of drift between Mailgun time and local server time. + def recent? + Time.at(timestamp) >= 2.minutes.ago + end + + def expected_signature + OpenSSL::HMAC.hexdigest OpenSSL::Digest::SHA256.new, key, "#{timestamp}#{token}" + end + end + end +end diff --git a/actionmailbox/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb b/actionmailbox/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb new file mode 100644 index 0000000000000..f72b10d7baa38 --- /dev/null +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/mandrill/inbound_emails_controller.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module ActionMailbox + # Ingests inbound emails from Mandrill. + # + # Requires a +mandrill_events+ parameter containing a JSON array of Mandrill inbound email event objects. + # Each event is expected to have a +msg+ object containing a full RFC 822 message in its +raw_msg+ property. + # + # Returns: + # + # - 204 No Content if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox + # - 401 Unauthorized if the request's signature could not be validated + # - 404 Not Found if Action Mailbox is not configured to accept inbound emails from Mandrill + # - 422 Unprocessable Entity if the request is missing required parameters + # - 500 Server Error if the Mandrill API key is missing, or one of the Active Record database, + # the Active Storage service, or the Active Job backend is misconfigured or unavailable + class Ingresses::Mandrill::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate, except: :health_check + + def create + raw_emails.each { |raw_email| ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email } + head :ok + rescue JSON::ParserError => error + logger.error error.message + head :unprocessable_entity + end + + def health_check + head :ok + end + + private + def raw_emails + events.select { |event| event["event"] == "inbound" }.collect { |event| event.dig("msg", "raw_msg") } + end + + def events + JSON.parse params.require(:mandrill_events) + end + + + def authenticate + head :unauthorized unless authenticated? + end + + def authenticated? + if key.present? + Authenticator.new(request, key).authenticated? + else + raise ArgumentError, <<~MESSAGE.squish + Missing required Mandrill API key. Set action_mailbox.mandrill_api_key in your application's + encrypted credentials or provide the MANDRILL_INGRESS_API_KEY environment variable. + MESSAGE + end + end + + def key + Rails.application.credentials.dig(:action_mailbox, :mandrill_api_key) || ENV["MANDRILL_INGRESS_API_KEY"] + end + + class Authenticator + attr_reader :request, :key + + def initialize(request, key) + @request, @key = request, key + end + + def authenticated? + ActiveSupport::SecurityUtils.secure_compare given_signature, expected_signature + end + + private + def given_signature + request.headers["X-Mandrill-Signature"] + end + + def expected_signature + Base64.strict_encode64 OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, key, message) + end + + def message + request.url + request.POST.sort.flatten.join + end + end + end +end diff --git a/actionmailbox/app/controllers/action_mailbox/ingresses/postmark/inbound_emails_controller.rb b/actionmailbox/app/controllers/action_mailbox/ingresses/postmark/inbound_emails_controller.rb new file mode 100644 index 0000000000000..db4e141627cea --- /dev/null +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/postmark/inbound_emails_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module ActionMailbox + # Ingests inbound emails from Postmark. Requires a +RawEmail+ parameter containing a full RFC 822 message. + # + # Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the + # password is read from the application's encrypted credentials or an environment variable. See the Usage section below. + # + # Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to + # the Postmark ingress can learn its password. You should only use the Postmark ingress over HTTPS. + # + # Returns: + # + # - 204 No Content if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox + # - 401 Unauthorized if the request's signature could not be validated + # - 404 Not Found if Action Mailbox is not configured to accept inbound emails from Postmark + # - 422 Unprocessable Entity if the request is missing the required +RawEmail+ parameter + # - 500 Server Error if the ingress password is not configured, or if one of the Active Record database, + # the Active Storage service, or the Active Job backend is misconfigured or unavailable + # + # == Usage + # + # 1. Tell Action Mailbox to accept emails from Postmark: + # + # # config/environments/production.rb + # config.action_mailbox.ingress = :postmark + # + # 2. Generate a strong password that Action Mailbox can use to authenticate requests to the Postmark ingress. + # + # Use bin/rails credentials:edit to add the password to your application's encrypted credentials under + # +action_mailbox.ingress_password+, where Action Mailbox will automatically find it: + # + # action_mailbox: + # ingress_password: ... + # + # Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable. + # + # 3. {Configure Postmark}[https://postmarkapp.com/manual#configure-your-inbound-webhook-url] to forward inbound emails + # to +/rails/action_mailbox/postmark/inbound_emails+ with the username +actionmailbox+ and the password you + # previously generated. If your application lived at https://example.com, you would configure your + # Postmark inbound webhook with the following fully-qualified URL: + # + # https://actionmailbox:PASSWORD@example.com/rails/action_mailbox/postmark/inbound_emails + # + # *NOTE:* When configuring your Postmark inbound webhook, be sure to check the box labeled *"Include raw email + # content in JSON payload"*. Action Mailbox needs the raw email content to work. + class Ingresses::Postmark::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate_by_password + param_encoding :create, "RawEmail", Encoding::ASCII_8BIT + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! mail + rescue ActionController::ParameterMissing => error + logger.error <<~MESSAGE + #{error.message} + + When configuring your Postmark inbound webhook, be sure to check the box + labeled "Include raw email content in JSON payload". + MESSAGE + head :unprocessable_entity + end + + private + def mail + params.require("RawEmail").tap do |raw_email| + raw_email.prepend("X-Original-To: ", params.require("OriginalRecipient"), "\n") if params.key?("OriginalRecipient") + end + end + end +end diff --git a/actionmailbox/app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb b/actionmailbox/app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb new file mode 100644 index 0000000000000..d569e04334bd1 --- /dev/null +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/relay/inbound_emails_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module ActionMailbox + # Ingests inbound emails relayed from an SMTP server. + # + # Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the + # password is read from the application's encrypted credentials or an environment variable. See the Usage section below. + # + # Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to + # the ingress can learn its password. You should only use this ingress over HTTPS. + # + # Returns: + # + # - 204 No Content if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox + # - 401 Unauthorized if the request could not be authenticated + # - 404 Not Found if Action Mailbox is not configured to accept inbound emails relayed from an SMTP server + # - 415 Unsupported Media Type if the request does not contain an RFC 822 message + # - 500 Server Error if the ingress password is not configured, or if one of the Active Record database, + # the Active Storage service, or the Active Job backend is misconfigured or unavailable + # + # == Usage + # + # 1. Tell Action Mailbox to accept emails from an SMTP relay: + # + # # config/environments/production.rb + # config.action_mailbox.ingress = :relay + # + # 2. Generate a strong password that Action Mailbox can use to authenticate requests to the ingress. + # + # Use bin/rails credentials:edit to add the password to your application's encrypted credentials under + # +action_mailbox.ingress_password+, where Action Mailbox will automatically find it: + # + # action_mailbox: + # ingress_password: ... + # + # Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable. + # + # 3. Configure your SMTP server to pipe inbound emails to the appropriate ingress command, providing the +URL+ of the + # relay ingress and the +INGRESS_PASSWORD+ you previously generated. + # + # If your application lives at https://example.com, you would configure the Postfix SMTP server to pipe + # inbound emails to the following command: + # + # $ bin/rails action_mailbox:ingress:postfix URL=https://example.com/rails/action_mailbox/postfix/inbound_emails INGRESS_PASSWORD=... + # + # Built-in ingress commands are available for these popular SMTP servers: + # + # - Exim (bin/rails action_mailbox:ingress:exim) + # - Postfix (bin/rails action_mailbox:ingress:postfix) + # - Qmail (bin/rails action_mailbox:ingress:qmail) + class Ingresses::Relay::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate_by_password, :require_valid_rfc822_message + + def create + if request.body + ActionMailbox::InboundEmail.create_and_extract_message_id! request.body.read + else + head :unprocessable_entity + end + end + + private + def require_valid_rfc822_message + unless request.media_type == "message/rfc822" + head :unsupported_media_type + end + end + end +end diff --git a/actionmailbox/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb b/actionmailbox/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb new file mode 100644 index 0000000000000..fc2d7aba926fa --- /dev/null +++ b/actionmailbox/app/controllers/action_mailbox/ingresses/sendgrid/inbound_emails_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module ActionMailbox + # Ingests inbound emails from SendGrid. Requires an +email+ parameter containing a full RFC 822 message. + # + # Authenticates requests using HTTP basic access authentication. The username is always +actionmailbox+, and the + # password is read from the application's encrypted credentials or an environment variable. See the Usage section below. + # + # Note that basic authentication is insecure over unencrypted HTTP. An attacker that intercepts cleartext requests to + # the SendGrid ingress can learn its password. You should only use the SendGrid ingress over HTTPS. + # + # Returns: + # + # - 204 No Content if an inbound email is successfully recorded and enqueued for routing to the appropriate mailbox + # - 401 Unauthorized if the request's signature could not be validated + # - 404 Not Found if Action Mailbox is not configured to accept inbound emails from SendGrid + # - 422 Unprocessable Entity if the request is missing the required +email+ parameter + # - 500 Server Error if the ingress password is not configured, or if one of the Active Record database, + # the Active Storage service, or the Active Job backend is misconfigured or unavailable + # + # == Usage + # + # 1. Tell Action Mailbox to accept emails from SendGrid: + # + # # config/environments/production.rb + # config.action_mailbox.ingress = :sendgrid + # + # 2. Generate a strong password that Action Mailbox can use to authenticate requests to the SendGrid ingress. + # + # Use bin/rails credentials:edit to add the password to your application's encrypted credentials under + # +action_mailbox.ingress_password+, where Action Mailbox will automatically find it: + # + # action_mailbox: + # ingress_password: ... + # + # Alternatively, provide the password in the +RAILS_INBOUND_EMAIL_PASSWORD+ environment variable. + # + # 3. {Configure SendGrid Inbound Parse}[https://sendgrid.com/docs/for-developers/parsing-email/setting-up-the-inbound-parse-webhook/] + # to forward inbound emails to +/rails/action_mailbox/sendgrid/inbound_emails+ with the username +actionmailbox+ and + # the password you previously generated. If your application lived at https://example.com, you would + # configure SendGrid with the following fully-qualified URL: + # + # https://actionmailbox:PASSWORD@example.com/rails/action_mailbox/sendgrid/inbound_emails + # + # *NOTE:* When configuring your SendGrid Inbound Parse webhook, be sure to check the box labeled *"Post the raw, + # full MIME message."* Action Mailbox needs the raw MIME message to work. + class Ingresses::Sendgrid::InboundEmailsController < ActionMailbox::BaseController + before_action :authenticate_by_password + param_encoding :create, :email, Encoding::ASCII_8BIT + + def create + ActionMailbox::InboundEmail.create_and_extract_message_id! mail + rescue JSON::ParserError => error + logger.error error.message + head :unprocessable_entity + end + + private + def mail + params.require(:email).tap do |raw_email| + envelope["to"].each { |to| raw_email.prepend("X-Original-To: ", to, "\n") } if params.key?(:envelope) + end + end + + def envelope + JSON.parse(params.require(:envelope)) + end + end +end diff --git a/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails/sources_controller.rb b/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails/sources_controller.rb new file mode 100644 index 0000000000000..dfc382c7dcdeb --- /dev/null +++ b/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails/sources_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# :enddoc: + +module Rails + class Conductor::ActionMailbox::InboundEmails::SourcesController < Rails::Conductor::BaseController # :nodoc: + def new + end + + def create + inbound_email = ActionMailbox::InboundEmail.create_and_extract_message_id! params[:source] + redirect_to main_app.rails_conductor_inbound_email_url(http://github.com/inbound_email) + end + end +end diff --git a/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb b/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb new file mode 100644 index 0000000000000..1146082b4b2b7 --- /dev/null +++ b/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# :enddoc: + +module Rails + class Conductor::ActionMailbox::InboundEmailsController < Rails::Conductor::BaseController + def index + @inbound_emails = ActionMailbox::InboundEmail.order(created_at: :desc) + end + + def new + end + + def show + @inbound_email = ActionMailbox::InboundEmail.find(params[:id]) + end + + def create + inbound_email = create_inbound_email(new_mail) + redirect_to main_app.rails_conductor_inbound_email_url(http://github.com/inbound_email) + end + + private + def new_mail + Mail.new(mail_params.except(:attachments).to_h).tap do |mail| + mail[:bcc]&.include_in_headers = true + mail_params[:attachments]&.select(&:present?)&.each do |attachment| + mail.add_file(filename: attachment.original_filename, content: attachment.read) + end + end + end + + def mail_params + params.expect(mail: [:from, :to, :cc, :bcc, :x_original_to, :in_reply_to, :subject, :body, attachments: []]) + end + + def create_inbound_email(mail) + ActionMailbox::InboundEmail.create_and_extract_message_id!(mail.to_s) + end + end +end diff --git a/actionmailbox/app/controllers/rails/conductor/action_mailbox/incinerates_controller.rb b/actionmailbox/app/controllers/rails/conductor/action_mailbox/incinerates_controller.rb new file mode 100644 index 0000000000000..2466eda8eac63 --- /dev/null +++ b/actionmailbox/app/controllers/rails/conductor/action_mailbox/incinerates_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# :enddoc: + +module Rails + # Incinerating will destroy an email that is due and has already been processed. + class Conductor::ActionMailbox::IncineratesController < Rails::Conductor::BaseController + def create + ActionMailbox::InboundEmail.find(params[:inbound_email_id]).incinerate + + redirect_to main_app.rails_conductor_inbound_emails_url + end + end +end diff --git a/actionmailbox/app/controllers/rails/conductor/action_mailbox/reroutes_controller.rb b/actionmailbox/app/controllers/rails/conductor/action_mailbox/reroutes_controller.rb new file mode 100644 index 0000000000000..a1f266d00b88d --- /dev/null +++ b/actionmailbox/app/controllers/rails/conductor/action_mailbox/reroutes_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# :enddoc: + +module Rails + # Rerouting will run routing and processing on an email that has already been, or attempted to be, processed. + class Conductor::ActionMailbox::ReroutesController < Rails::Conductor::BaseController + def create + inbound_email = ActionMailbox::InboundEmail.find(params[:inbound_email_id]) + reroute inbound_email + + redirect_to main_app.rails_conductor_inbound_email_url(http://github.com/inbound_email) + end + + private + def reroute(inbound_email) + inbound_email.pending! + inbound_email.route_later + end + end +end diff --git a/actionmailbox/app/controllers/rails/conductor/base_controller.rb b/actionmailbox/app/controllers/rails/conductor/base_controller.rb new file mode 100644 index 0000000000000..93c4e2a66e7a6 --- /dev/null +++ b/actionmailbox/app/controllers/rails/conductor/base_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Rails + # TODO: Move this to Rails::Conductor gem + class Conductor::BaseController < ActionController::Base + layout "rails/conductor" + before_action :ensure_development_env + + private + def ensure_development_env + head :forbidden unless Rails.env.development? + end + end +end diff --git a/actionmailbox/app/jobs/action_mailbox/incineration_job.rb b/actionmailbox/app/jobs/action_mailbox/incineration_job.rb new file mode 100644 index 0000000000000..351bd67c693f9 --- /dev/null +++ b/actionmailbox/app/jobs/action_mailbox/incineration_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ActionMailbox + # You can configure when this +IncinerationJob+ will be run as a time-after-processing using the + # +config.action_mailbox.incinerate_after+ or +ActionMailbox.incinerate_after+ setting. + # + # Since this incineration is set for the future, it'll automatically ignore any InboundEmails + # that have already been deleted and discard itself if so. + # + # You can disable incinerating processed emails by setting +config.action_mailbox.incinerate+ or + # +ActionMailbox.incinerate+ to +false+. + class IncinerationJob < ActiveJob::Base + queue_as { ActionMailbox.queues[:incineration] } + + discard_on ActiveRecord::RecordNotFound + + def self.schedule(inbound_email) + set(wait: ActionMailbox.incinerate_after).perform_later(inbound_email) + end + + def perform(inbound_email) + inbound_email.incinerate + end + end +end diff --git a/actionmailbox/app/jobs/action_mailbox/routing_job.rb b/actionmailbox/app/jobs/action_mailbox/routing_job.rb new file mode 100644 index 0000000000000..4ddf6e4231015 --- /dev/null +++ b/actionmailbox/app/jobs/action_mailbox/routing_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module ActionMailbox + # Routing a new InboundEmail is an asynchronous operation, which allows the ingress controllers to quickly + # accept new incoming emails without being burdened to hang while they're actually being processed. + class RoutingJob < ActiveJob::Base + queue_as { ActionMailbox.queues[:routing] } + + def perform(inbound_email) + inbound_email.route + end + end +end diff --git a/actionmailbox/app/models/action_mailbox/inbound_email.rb b/actionmailbox/app/models/action_mailbox/inbound_email.rb new file mode 100644 index 0000000000000..76af7caf922f1 --- /dev/null +++ b/actionmailbox/app/models/action_mailbox/inbound_email.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "mail" + +module ActionMailbox + # The +InboundEmail+ is an Active Record that keeps a reference to the raw email stored in Active Storage + # and tracks the status of processing. By default, incoming emails will go through the following lifecycle: + # + # * Pending: Just received by one of the ingress controllers and scheduled for routing. + # * Processing: During active processing, while a specific mailbox is running its #process method. + # * Delivered: Successfully processed by the specific mailbox. + # * Failed: An exception was raised during the specific mailbox's execution of the +#process+ method. + # * Bounced: Rejected processing by the specific mailbox and bounced to sender. + # + # Once the +InboundEmail+ has reached the status of being either +delivered+, +failed+, or +bounced+, + # it'll count as having been +#processed?+. Once processed, the +InboundEmail+ will be scheduled for + # automatic incineration at a later point. + # + # When working with an +InboundEmail+, you'll usually interact with the parsed version of the source, + # which is available as a +Mail+ object from +#mail+. But you can also access the raw source directly + # using the +#source+ method. + # + # Examples: + # + # inbound_email.mail.from # => 'david@loudthinking.com' + # inbound_email.source # Returns the full rfc822 source of the email as text + class InboundEmail < Record + include Incineratable, MessageId, Routable + + has_one_attached :raw_email, service: ActionMailbox.storage_service + enum :status, %i[ pending processing delivered failed bounced ] + + def mail + @mail ||= Mail.from_source(source) + end + + def source + @source ||= raw_email.download + end + + def processed? + delivered? || failed? || bounced? + end + + def instrumentation_payload # :nodoc: + { + id: id, + message_id: message_id, + status: status + } + end + end +end + +ActiveSupport.run_load_hooks :action_mailbox_inbound_email, ActionMailbox::InboundEmail diff --git a/actionmailbox/app/models/action_mailbox/inbound_email/incineratable.rb b/actionmailbox/app/models/action_mailbox/inbound_email/incineratable.rb new file mode 100644 index 0000000000000..e7c8782f3394d --- /dev/null +++ b/actionmailbox/app/models/action_mailbox/inbound_email/incineratable.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Ensure that the +InboundEmail+ is automatically scheduled for later incineration if the status has been +# changed to +processed+. The later incineration will be invoked at the time specified by the +# +ActionMailbox.incinerate_after+ time using the +IncinerationJob+. +module ActionMailbox::InboundEmail::Incineratable + extend ActiveSupport::Concern + + included do + after_update_commit :incinerate_later, if: -> { ActionMailbox.incinerate && status_previously_changed? && processed? } + end + + def incinerate_later + ActionMailbox::IncinerationJob.schedule self + end + + def incinerate + Incineration.new(self).run + end +end diff --git a/actionmailbox/app/models/action_mailbox/inbound_email/incineratable/incineration.rb b/actionmailbox/app/models/action_mailbox/inbound_email/incineratable/incineration.rb new file mode 100644 index 0000000000000..dabc83fae671d --- /dev/null +++ b/actionmailbox/app/models/action_mailbox/inbound_email/incineratable/incineration.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ActionMailbox + # Command class for carrying out the actual incineration of the +InboundMail+ that's been scheduled + # for removal. Before the incineration – which really is just a call to +#destroy!+ – is run, we verify + # that it's both eligible (by virtue of having already been processed) and time to do so (that is, + # the +InboundEmail+ was processed after the +incinerate_after+ time). + class InboundEmail::Incineratable::Incineration + def initialize(inbound_email) + @inbound_email = inbound_email + end + + def run + @inbound_email.destroy! if due? && processed? + end + + private + def due? + @inbound_email.updated_at < ActionMailbox.incinerate_after.ago.end_of_day + end + + def processed? + @inbound_email.processed? + end + end +end diff --git a/actionmailbox/app/models/action_mailbox/inbound_email/message_id.rb b/actionmailbox/app/models/action_mailbox/inbound_email/message_id.rb new file mode 100644 index 0000000000000..78392f02ee772 --- /dev/null +++ b/actionmailbox/app/models/action_mailbox/inbound_email/message_id.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# The +Message-ID+ as specified by rfc822 is supposed to be a unique identifier for that individual email. +# That makes it an ideal tracking token for debugging and forensics, just like +X-Request-Id+ does for +# web request. +# +# If an inbound email does not, against the rfc822 mandate, specify a Message-ID, one will be generated +# using the approach from +Mail::MessageIdField+. +module ActionMailbox::InboundEmail::MessageId + extend ActiveSupport::Concern + + class_methods do + # Create a new +InboundEmail+ from the raw +source+ of the email, which is uploaded as an Active Storage + # attachment called +raw_email+. Before the upload, extract the Message-ID from the +source+ and set + # it as an attribute on the new +InboundEmail+. + def create_and_extract_message_id!(source, **options) + message_checksum = OpenSSL::Digest::SHA1.hexdigest(source) + message_id = extract_message_id(source) || generate_missing_message_id(message_checksum) + + create! raw_email: create_and_upload_raw_email!(source), + message_id: message_id, message_checksum: message_checksum, **options + rescue ActiveRecord::RecordNotUnique + nil + end + + private + def extract_message_id(source) + Mail.from_source(source).message_id rescue nil + end + + def generate_missing_message_id(message_checksum) + Mail::MessageIdField.new("<#{message_checksum}@#{::Socket.gethostname}.mail>").message_id.tap do |message_id| + logger.warn "Message-ID couldn't be parsed or is missing. Generated a new Message-ID: #{message_id}" + end + end + + def create_and_upload_raw_email!(source) + ActiveStorage::Blob.create_and_upload! io: StringIO.new(source), filename: "message.eml", content_type: "message/rfc822", + service_name: ActionMailbox.storage_service + end + end +end diff --git a/actionmailbox/app/models/action_mailbox/inbound_email/routable.rb b/actionmailbox/app/models/action_mailbox/inbound_email/routable.rb new file mode 100644 index 0000000000000..39565df16636b --- /dev/null +++ b/actionmailbox/app/models/action_mailbox/inbound_email/routable.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# A newly received +InboundEmail+ will not be routed synchronously as part of ingress controller's receival. +# Instead, the routing will be done asynchronously, using a +RoutingJob+, to ensure maximum parallel capacity. +# +# By default, all newly created +InboundEmail+ records that have the status of +pending+, which is the default, +# will be scheduled for automatic, deferred routing. +module ActionMailbox::InboundEmail::Routable + extend ActiveSupport::Concern + + included do + after_create_commit :route_later, if: :pending? + end + + # Enqueue a +RoutingJob+ for this +InboundEmail+. + def route_later + ActionMailbox::RoutingJob.perform_later self + end + + # Route this +InboundEmail+ using the routing rules declared on the +ApplicationMailbox+. + def route + ApplicationMailbox.route self + end +end diff --git a/actionmailbox/app/models/action_mailbox/record.rb b/actionmailbox/app/models/action_mailbox/record.rb new file mode 100644 index 0000000000000..77b3a99e8978f --- /dev/null +++ b/actionmailbox/app/models/action_mailbox/record.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ActionMailbox + class Record < ActiveRecord::Base # :nodoc: + self.abstract_class = true + end +end + +ActiveSupport.run_load_hooks :action_mailbox_record, ActionMailbox::Record diff --git a/actionmailbox/app/views/layouts/rails/conductor.html.erb b/actionmailbox/app/views/layouts/rails/conductor.html.erb new file mode 100644 index 0000000000000..1cad6560c4b01 --- /dev/null +++ b/actionmailbox/app/views/layouts/rails/conductor.html.erb @@ -0,0 +1,8 @@ + + + Rails Conductor: <%= yield :title %> + + +<%= yield %> + + diff --git a/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/index.html.erb b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/index.html.erb new file mode 100644 index 0000000000000..65f64bf56d7a9 --- /dev/null +++ b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/index.html.erb @@ -0,0 +1,16 @@ +<% provide :title, "Deliver new inbound email" %> + +

All inbound emails

+ +<%= link_to "New inbound email by form", main_app.new_rails_conductor_inbound_email_path %> | +<%= link_to "New inbound email by source", main_app.new_rails_conductor_inbound_email_source_path %> + + + + <% @inbound_emails.each do |inbound_email| %> + + + + + <% end %> +
Message IDStatus
<%= link_to inbound_email.message_id, main_app.rails_conductor_inbound_email_path(inbound_email) %><%= inbound_email.status %>
diff --git a/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/new.html.erb b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/new.html.erb new file mode 100644 index 0000000000000..5b0e639509a6d --- /dev/null +++ b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/new.html.erb @@ -0,0 +1,52 @@ +<% provide :title, "Deliver new inbound email" %> + +

Deliver new inbound email

+ +<%= form_with(url: main_app.rails_conductor_inbound_emails_path, scope: :mail, local: true) do |form| %> +
+ <%= form.label :from, "From" %>
+ <%= form.text_field :from, value: params[:from] %> +
+ +
+ <%= form.label :to, "To" %>
+ <%= form.text_field :to, value: params[:to] %> +
+ +
+ <%= form.label :cc, "CC" %>
+ <%= form.text_field :cc, value: params[:cc] %> +
+ +
+ <%= form.label :bcc, "BCC" %>
+ <%= form.text_field :bcc, value: params[:bcc] %> +
+ +
+ <%= form.label :x_original_to, "X-Original-To" %>
+ <%= form.text_field :x_original_to, value: params[:x_original_to] %> +
+ +
+ <%= form.label :in_reply_to, "In-Reply-To" %>
+ <%= form.text_field :in_reply_to, value: params[:in_reply_to] %> +
+ +
+ <%= form.label :subject, "Subject" %>
+ <%= form.text_field :subject, value: params[:subject] %> +
+ +
+ <%= form.label :body, "Body" %>
+ <%= form.textarea :body, size: "40x20", value: params[:body] %> +
+ +
+ <%= form.label :attachments, "Attachments" %>
+ <%= form.file_field :attachments, multiple: true %> +
+ + <%= form.submit "Deliver inbound email" %> +<% end %> diff --git a/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/show.html.erb b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/show.html.erb new file mode 100644 index 0000000000000..48c08cd040ad3 --- /dev/null +++ b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/show.html.erb @@ -0,0 +1,15 @@ +<% provide :title, @inbound_email.message_id %> + +

<%= @inbound_email.message_id %>: <%= @inbound_email.status %>

+ +
    +
  • <%= button_to "Route again", main_app.rails_conductor_inbound_email_reroute_path(@inbound_email), method: :post %>
  • +
  • <%= button_to "Incinerate", main_app.rails_conductor_inbound_email_incinerate_path(@inbound_email), method: :post %>
  • +
+ +
+ Full email source +
<%= @inbound_email.source %>
+
+ +<%= link_to "Back to all inbound emails", main_app.rails_conductor_inbound_emails_path %> \ No newline at end of file diff --git a/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/sources/new.html.erb b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/sources/new.html.erb new file mode 100644 index 0000000000000..82b6ea8dd1bb9 --- /dev/null +++ b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/sources/new.html.erb @@ -0,0 +1,12 @@ +<% provide :title, "Deliver new inbound email by source" %> + +

Deliver new inbound email by source

+ +<%= form_with(url: main_app.rails_conductor_inbound_email_sources_path, local: true) do |form| %> +
+ <%= form.label :source, "Source" %>
+ <%= form.textarea :source, size: "80x60" %> +
+ + <%= form.submit "Deliver inbound email" %> +<% end %> diff --git a/actionmailbox/bin/test b/actionmailbox/bin/test new file mode 100755 index 0000000000000..c53377cc970f4 --- /dev/null +++ b/actionmailbox/bin/test @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +COMPONENT_ROOT = File.expand_path("..", __dir__) +require_relative "../../tools/test" diff --git a/actionmailbox/config/routes.rb b/actionmailbox/config/routes.rb new file mode 100644 index 0000000000000..e3d5737a631f3 --- /dev/null +++ b/actionmailbox/config/routes.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + scope "/rails/action_mailbox", module: "action_mailbox/ingresses" do + post "/postmark/inbound_emails" => "postmark/inbound_emails#create", as: :rails_postmark_inbound_emails + post "/relay/inbound_emails" => "relay/inbound_emails#create", as: :rails_relay_inbound_emails + post "/sendgrid/inbound_emails" => "sendgrid/inbound_emails#create", as: :rails_sendgrid_inbound_emails + + # Mandrill checks for the existence of a URL with a HEAD request before it will create the webhook. + get "/mandrill/inbound_emails" => "mandrill/inbound_emails#health_check", as: :rails_mandrill_inbound_health_check + post "/mandrill/inbound_emails" => "mandrill/inbound_emails#create", as: :rails_mandrill_inbound_emails + + # Mailgun requires that a webhook's URL end in 'mime' for it to receive the raw contents of emails. + post "/mailgun/inbound_emails/mime" => "mailgun/inbound_emails#create", as: :rails_mailgun_inbound_emails + end + + # TODO: Should these be mounted within the engine only? + scope "rails/conductor/action_mailbox/", module: "rails/conductor/action_mailbox" do + resources :inbound_emails, as: :rails_conductor_inbound_emails, only: %i[index new show create] + get "inbound_emails/sources/new", to: "inbound_emails/sources#new", as: :new_rails_conductor_inbound_email_source + post "inbound_emails/sources", to: "inbound_emails/sources#create", as: :rails_conductor_inbound_email_sources + + post ":inbound_email_id/reroute" => "reroutes#create", as: :rails_conductor_inbound_email_reroute + post ":inbound_email_id/incinerate" => "incinerates#create", as: :rails_conductor_inbound_email_incinerate + end +end diff --git a/actionmailbox/db/migrate/20180917164000_create_action_mailbox_tables.rb b/actionmailbox/db/migrate/20180917164000_create_action_mailbox_tables.rb new file mode 100644 index 0000000000000..2a1e2f7a8e2e1 --- /dev/null +++ b/actionmailbox/db/migrate/20180917164000_create_action_mailbox_tables.rb @@ -0,0 +1,19 @@ +class CreateActionMailboxTables < ActiveRecord::Migration[6.0] + def change + create_table :action_mailbox_inbound_emails, id: primary_key_type do |t| + t.integer :status, default: 0, null: false + t.string :message_id, null: false + t.string :message_checksum, null: false + + t.timestamps + + t.index [ :message_id, :message_checksum ], name: "index_action_mailbox_inbound_emails_uniqueness", unique: true + end + end + + private + def primary_key_type + config = Rails.configuration.generators + config.options[config.orm][:primary_key_type] || :primary_key + end +end diff --git a/actionmailbox/lib/action_mailbox.rb b/actionmailbox/lib/action_mailbox.rb new file mode 100644 index 0000000000000..d0e6b1600d840 --- /dev/null +++ b/actionmailbox/lib/action_mailbox.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "active_support" +require "active_support/rails" +require "active_support/core_ext/numeric/time" + +require "action_mailbox/version" +require "action_mailbox/deprecator" +require "action_mailbox/mail_ext" + +# :markup: markdown +# :include: ../README.md +module ActionMailbox + extend ActiveSupport::Autoload + + autoload :Base + autoload :Router + autoload :TestCase + + mattr_accessor :ingress + mattr_accessor :logger + mattr_accessor :incinerate, default: true + mattr_accessor :incinerate_after, default: 30.days + mattr_accessor :queues, default: {} + mattr_accessor :storage_service +end diff --git a/actionmailbox/lib/action_mailbox/base.rb b/actionmailbox/lib/action_mailbox/base.rb new file mode 100644 index 0000000000000..d6ff9caf404bb --- /dev/null +++ b/actionmailbox/lib/action_mailbox/base.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "active_support/rescuable" + +require "action_mailbox/callbacks" +require "action_mailbox/routing" + +module ActionMailbox + # = Action Mailbox \Base + # + # The base class for all application mailboxes. Not intended to be inherited from directly. Inherit from + # +ApplicationMailbox+ instead, as that's where the app-specific routing is configured. This routing + # is specified in the following ways: + # + # class ApplicationMailbox < ActionMailbox::Base + # # Any of the recipients of the mail (whether to, cc, bcc) are matched against the regexp. + # routing /^replies@/i => :replies + # + # # Any of the recipients of the mail (whether to, cc, bcc) needs to be an exact match for the string. + # routing "help@example.com" => :help + # + # # Any callable (proc, lambda, etc) object is passed the inbound_email record and is a match if true. + # routing ->(inbound_email) { inbound_email.mail.to.size > 2 } => :multiple_recipients + # + # # Any object responding to #match? is called with the inbound_email record as an argument. Match if true. + # routing CustomAddress.new => :custom + # + # # Any inbound_email that has not been already matched will be sent to the BackstopMailbox. + # routing :all => :backstop + # end + # + # Application mailboxes need to override the #process method, which is invoked by the framework after + # callbacks have been run. The callbacks available are: +before_processing+, +after_processing+, and + # +around_processing+. The primary use case is to ensure that certain preconditions to processing are fulfilled + # using +before_processing+ callbacks. + # + # If a precondition fails to be met, you can halt the processing using the +#bounced!+ method, + # which will silently prevent any further processing, but not actually send out any bounce notice. You + # can also pair this behavior with the invocation of an Action Mailer class responsible for sending out + # an actual bounce email. This is done using the #bounce_with method, which takes the mail object returned + # by an Action Mailer method, like so: + # + # class ForwardsMailbox < ApplicationMailbox + # before_processing :ensure_sender_is_a_user + # + # private + # def ensure_sender_is_a_user + # unless User.exist?(email_address: mail.from) + # bounce_with UserRequiredMailer.missing(inbound_email) + # end + # end + # end + # + # During the processing of the inbound email, the status will be tracked. Before processing begins, + # the email will normally have the +pending+ status. Once processing begins, just before callbacks + # and the #process method is called, the status is changed to +processing+. If processing is allowed to + # complete, the status is changed to +delivered+. If a bounce is triggered, then +bounced+. If an unhandled + # exception is bubbled up, then +failed+. + # + # Exceptions can be handled at the class level using the familiar + # ActiveSupport::Rescuable approach: + # + # class ForwardsMailbox < ApplicationMailbox + # rescue_from(ApplicationSpecificVerificationError) { bounced! } + # end + class Base + include ActiveSupport::Rescuable + include ActionMailbox::Callbacks, ActionMailbox::Routing + + attr_reader :inbound_email + delegate :mail, :delivered!, :bounced!, to: :inbound_email + + delegate :logger, to: ActionMailbox + + def self.receive(inbound_email) + new(inbound_email).perform_processing + end + + def initialize(inbound_email) + @inbound_email = inbound_email + end + + def perform_processing # :nodoc: + ActiveSupport::Notifications.instrument "process.action_mailbox", instrumentation_payload do + track_status_of_inbound_email do + run_callbacks :process do + process + end + end + rescue => exception + # TODO: Include a reference to the inbound_email in the exception raised so error handling becomes easier + rescue_with_handler(exception) || raise + end + end + + def process + # Override in subclasses + end + + def finished_processing? # :nodoc: + inbound_email.delivered? || inbound_email.bounced? + end + + # Enqueues the given +message+ for delivery and changes the inbound email's status to +:bounced+. + def bounce_with(message) + inbound_email.bounced! + message.deliver_later + end + + # Immediately sends the given +message+ and changes the inbound email's status to +:bounced+. + def bounce_now_with(message) + inbound_email.bounced! + message.deliver_now + end + + private + def instrumentation_payload + { + mailbox: self, + inbound_email: inbound_email.instrumentation_payload + } + end + + def track_status_of_inbound_email + inbound_email.processing! + yield + inbound_email.delivered! unless inbound_email.bounced? + rescue + inbound_email.failed! + raise + end + end +end + +ActiveSupport.run_load_hooks :action_mailbox, ActionMailbox::Base diff --git a/actionmailbox/lib/action_mailbox/callbacks.rb b/actionmailbox/lib/action_mailbox/callbacks.rb new file mode 100644 index 0000000000000..cc801083dd091 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/callbacks.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "active_support/callbacks" + +module ActionMailbox + # = Action Mailbox \Callbacks + # + # Defines the callbacks related to processing. + module Callbacks + extend ActiveSupport::Concern + include ActiveSupport::Callbacks + + TERMINATOR = ->(mailbox, chain) do + chain.call + mailbox.finished_processing? + end + + included do + define_callbacks :process, terminator: TERMINATOR, skip_after_callbacks_if_terminated: true + end + + class_methods do + def before_processing(*methods, &block) + set_callback(:process, :before, *methods, &block) + end + + def after_processing(*methods, &block) + set_callback(:process, :after, *methods, &block) + end + + def around_processing(*methods, &block) + set_callback(:process, :around, *methods, &block) + end + end + end +end diff --git a/actionmailbox/lib/action_mailbox/deprecator.rb b/actionmailbox/lib/action_mailbox/deprecator.rb new file mode 100644 index 0000000000000..675becfc42921 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/deprecator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ActionMailbox + def self.deprecator # :nodoc: + @deprecator ||= ActiveSupport::Deprecation.new + end +end diff --git a/actionmailbox/lib/action_mailbox/engine.rb b/actionmailbox/lib/action_mailbox/engine.rb new file mode 100644 index 0000000000000..d0dc5c8ce29a7 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/engine.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails" +require "action_controller/railtie" +require "active_job/railtie" +require "active_record/railtie" +require "active_storage/engine" + +require "action_mailbox" + +module ActionMailbox + class Engine < Rails::Engine + isolate_namespace ActionMailbox + config.eager_load_namespaces << ActionMailbox + + config.action_mailbox = ActiveSupport::OrderedOptions.new + config.action_mailbox.incinerate = true + config.action_mailbox.incinerate_after = 30.days + + config.action_mailbox.queues = ActiveSupport::InheritableOptions.new \ + incineration: :action_mailbox_incineration, routing: :action_mailbox_routing + + config.action_mailbox.storage_service = nil + + initializer "action_mailbox.deprecator", before: :load_environment_config do |app| + app.deprecators[:action_mailbox] = ActionMailbox.deprecator + end + + initializer "action_mailbox.config" do + config.after_initialize do |app| + ActionMailbox.logger = app.config.action_mailbox.logger || Rails.logger + ActionMailbox.incinerate = app.config.action_mailbox.incinerate.nil? ? true : app.config.action_mailbox.incinerate + ActionMailbox.incinerate_after = app.config.action_mailbox.incinerate_after || 30.days + ActionMailbox.queues = app.config.action_mailbox.queues || {} + ActionMailbox.ingress = app.config.action_mailbox.ingress + ActionMailbox.storage_service = app.config.action_mailbox.storage_service + end + end + end +end diff --git a/actionmailbox/lib/action_mailbox/gem_version.rb b/actionmailbox/lib/action_mailbox/gem_version.rb new file mode 100644 index 0000000000000..d351800d34bbb --- /dev/null +++ b/actionmailbox/lib/action_mailbox/gem_version.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ActionMailbox + # Returns the currently loaded version of Action Mailbox as a +Gem::Version+. + def self.gem_version + Gem::Version.new VERSION::STRING + end + + module VERSION + MAJOR = 8 + MINOR = 1 + TINY = 0 + PRE = "alpha" + + STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") + end +end diff --git a/actionmailbox/lib/action_mailbox/mail_ext.rb b/actionmailbox/lib/action_mailbox/mail_ext.rb new file mode 100644 index 0000000000000..c4d277a1f9d7a --- /dev/null +++ b/actionmailbox/lib/action_mailbox/mail_ext.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require "mail" + +# The hope is to upstream most of these basic additions to the Mail gem's Mail object. But until then, here they lay! +Dir["#{File.expand_path(File.dirname(__FILE__))}/mail_ext/*"].each { |path| require "action_mailbox/mail_ext/#{File.basename(path)}" } diff --git a/actionmailbox/lib/action_mailbox/mail_ext/address_equality.rb b/actionmailbox/lib/action_mailbox/mail_ext/address_equality.rb new file mode 100644 index 0000000000000..39a43b3468601 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/mail_ext/address_equality.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Mail + class Address + def ==(other_address) + other_address.is_a?(Mail::Address) && to_s == other_address.to_s + end + end +end diff --git a/actionmailbox/lib/action_mailbox/mail_ext/address_wrapping.rb b/actionmailbox/lib/action_mailbox/mail_ext/address_wrapping.rb new file mode 100644 index 0000000000000..19eb624c1c0b0 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/mail_ext/address_wrapping.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Mail + class Address + def self.wrap(address) + address.is_a?(Mail::Address) ? address : Mail::Address.new(address) + end + end +end diff --git a/actionmailbox/lib/action_mailbox/mail_ext/addresses.rb b/actionmailbox/lib/action_mailbox/mail_ext/addresses.rb new file mode 100644 index 0000000000000..5961088c781c0 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/mail_ext/addresses.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Mail + class Message + def from_address + address_list(header[:from])&.addresses&.first + end + + def reply_to_address + address_list(header[:reply_to])&.addresses&.first + end + + def recipients_addresses + to_addresses + cc_addresses + bcc_addresses + x_original_to_addresses + x_forwarded_to_addresses + end + + def to_addresses + Array(address_list(header[:to])&.addresses) + end + + def cc_addresses + Array(address_list(header[:cc])&.addresses) + end + + def bcc_addresses + Array(address_list(header[:bcc])&.addresses) + end + + def x_original_to_addresses + Array(header[:x_original_to]).collect { |header| Mail::Address.new header.to_s } + end + + def x_forwarded_to_addresses + Array(header[:x_forwarded_to]).collect { |header| Mail::Address.new header.to_s } + end + + private + def address_list(obj) + if obj.respond_to?(:element) + # Mail 2.8+ + obj.element + else + # Mail <= 2.7.x + obj&.address_list + end + end + end +end diff --git a/actionmailbox/lib/action_mailbox/mail_ext/from_source.rb b/actionmailbox/lib/action_mailbox/mail_ext/from_source.rb new file mode 100644 index 0000000000000..17b7fc80ad536 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/mail_ext/from_source.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Mail + def self.from_source(source) + Mail.new Mail::Utilities.binary_unsafe_to_crlf(source.to_s) + end +end diff --git a/actionmailbox/lib/action_mailbox/mail_ext/recipients.rb b/actionmailbox/lib/action_mailbox/mail_ext/recipients.rb new file mode 100644 index 0000000000000..102ec83debe4b --- /dev/null +++ b/actionmailbox/lib/action_mailbox/mail_ext/recipients.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Mail + class Message + def recipients + Array(to) + Array(cc) + Array(bcc) + Array(header[:x_original_to]).map(&:to_s) + + Array(header[:x_forwarded_to]).map(&:to_s) + end + end +end diff --git a/actionmailbox/lib/action_mailbox/relayer.rb b/actionmailbox/lib/action_mailbox/relayer.rb new file mode 100644 index 0000000000000..e2890acb608ab --- /dev/null +++ b/actionmailbox/lib/action_mailbox/relayer.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "action_mailbox/version" +require "net/http" +require "uri" + +module ActionMailbox + class Relayer + class Result < Struct.new(:status_code, :message) + def success? + !failure? + end + + def failure? + transient_failure? || permanent_failure? + end + + def transient_failure? + status_code.start_with?("4.") + end + + def permanent_failure? + status_code.start_with?("5.") + end + end + + CONTENT_TYPE = "message/rfc822" + USER_AGENT = "Action Mailbox relayer v#{ActionMailbox.version}" + + attr_reader :uri, :username, :password + + def initialize(url:, username: "actionmailbox", password:) + @uri, @username, @password = URI(url), username, password + end + + def relay(source) + case response = post(source) + when Net::HTTPSuccess + Result.new "2.0.0", "Successfully relayed message to ingress" + when Net::HTTPUnauthorized + Result.new "4.7.0", "Invalid credentials for ingress" + else + Result.new "4.0.0", "HTTP #{response.code}" + end + rescue IOError, SocketError, SystemCallError => error + Result.new "4.4.2", "Network error relaying to ingress: #{error.message}" + rescue Timeout::Error + Result.new "4.4.2", "Timed out relaying to ingress" + rescue => error + Result.new "4.0.0", "Error relaying to ingress: #{error.message}" + end + + private + def post(source) + client.post uri, source, + "Content-Type" => CONTENT_TYPE, + "User-Agent" => USER_AGENT, + "Authorization" => "Basic #{Base64.strict_encode64(username + ":" + password)}" + end + + def client + @client ||= Net::HTTP.new(uri.host, uri.port).tap do |connection| + if uri.scheme == "https" + require "openssl" + + connection.use_ssl = true + connection.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + + connection.open_timeout = 1 + connection.read_timeout = 10 + end + end + end +end diff --git a/actionmailbox/lib/action_mailbox/router.rb b/actionmailbox/lib/action_mailbox/router.rb new file mode 100644 index 0000000000000..0b67266af5c83 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/router.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module ActionMailbox + # = Action Mailbox \Router + # + # Encapsulates the routes that live on the ApplicationMailbox and performs the actual routing when + # an inbound_email is received. + class Router + class RoutingError < StandardError; end + + def initialize + @routes = [] + end + + def add_routes(routes) + routes.each do |(address, mailbox_name)| + add_route address, to: mailbox_name + end + end + + def add_route(address, to:) + routes.append Route.new(address, to: to) + end + + def route(inbound_email) + if mailbox = mailbox_for(inbound_email) + mailbox.receive(inbound_email) + else + inbound_email.bounced! + + raise RoutingError + end + end + + def mailbox_for(inbound_email) + routes.detect { |route| route.match?(inbound_email) }&.mailbox_class + end + + private + attr_reader :routes + end +end + +require "action_mailbox/router/route" diff --git a/actionmailbox/lib/action_mailbox/router/route.rb b/actionmailbox/lib/action_mailbox/router/route.rb new file mode 100644 index 0000000000000..7c67c775900f7 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/router/route.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module ActionMailbox + # Encapsulates a route, which can then be matched against an inbound_email and provide a lookup of the matching + # mailbox class. See examples for the different route addresses and how to use them in the ActionMailbox::Base + # documentation. + class Router::Route + attr_reader :address, :mailbox_name + + def initialize(address, to:) + @address, @mailbox_name = address, to + + ensure_valid_address + end + + def match?(inbound_email) + case address + when :all + true + when String + inbound_email.mail.recipients.any? { |recipient| address.casecmp?(recipient) } + when Regexp + inbound_email.mail.recipients.any? { |recipient| address.match?(recipient) } + when Proc + address.call(inbound_email) + else + address.match?(inbound_email) + end + end + + def mailbox_class + "#{mailbox_name.to_s.camelize}Mailbox".constantize + end + + private + def ensure_valid_address + unless [ Symbol, String, Regexp, Proc ].any? { |klass| address.is_a?(klass) } || address.respond_to?(:match?) + raise ArgumentError, "Expected a Symbol, String, Regexp, Proc, or matchable, got #{address.inspect}" + end + end + end +end diff --git a/actionmailbox/lib/action_mailbox/routing.rb b/actionmailbox/lib/action_mailbox/routing.rb new file mode 100644 index 0000000000000..4e98d4ee0b29f --- /dev/null +++ b/actionmailbox/lib/action_mailbox/routing.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ActionMailbox + # See ActionMailbox::Base for how to specify routing. + module Routing + extend ActiveSupport::Concern + + included do + cattr_accessor :router, default: ActionMailbox::Router.new + end + + class_methods do + def routing(routes) + router.add_routes(routes) + end + + def route(inbound_email) + router.route(inbound_email) + end + + def mailbox_for(inbound_email) + router.mailbox_for(inbound_email) + end + end + end +end diff --git a/actionmailbox/lib/action_mailbox/test_case.rb b/actionmailbox/lib/action_mailbox/test_case.rb new file mode 100644 index 0000000000000..5e78e428d3309 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/test_case.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "action_mailbox/test_helper" +require "active_support/test_case" + +module ActionMailbox + class TestCase < ActiveSupport::TestCase + include ActionMailbox::TestHelper + end +end + +ActiveSupport.run_load_hooks :action_mailbox_test_case, ActionMailbox::TestCase diff --git a/actionmailbox/lib/action_mailbox/test_helper.rb b/actionmailbox/lib/action_mailbox/test_helper.rb new file mode 100644 index 0000000000000..ea50afd7f0a61 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/test_helper.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "mail" + +module ActionMailbox + module TestHelper + # Create an InboundEmail record using an eml fixture in the format of message/rfc822 + # referenced with +fixture_name+ located in +test/fixtures/files/fixture_name+. + def create_inbound_email_from_fixture(fixture_name, status: :processing) + create_inbound_email_from_source file_fixture(fixture_name).read, status: status + end + + # Creates an InboundEmail by specifying through options or a block. + # + # ==== Options + # + # * :status - The +status+ to set for the created InboundEmail. + # For possible statuses, see its documentation. + # + # ==== Creating a simple email + # + # When you only need to set basic fields like +from+, +to+, +subject+, and + # +body+, you can pass them directly as options. + # + # create_inbound_email_from_mail(from: "david@loudthinking.com", subject: "Hello!") + # + # ==== Creating a multi-part email + # + # When you need to create a more intricate email, like a multi-part email + # that contains both a plaintext version and an HTML version, you can pass a + # block. + # + # create_inbound_email_from_mail do + # to "David Heinemeier Hansson " + # from "Bilbo Baggins " + # subject "Come down to the Shire!" + # + # text_part do + # body "Please join us for a party at Bag End" + # end + # + # html_part do + # body "

Please join us for a party at Bag End

" + # end + # end + # + # As with +Mail.new+, you can also use a block parameter to define the parts + # of the message: + # + # create_inbound_email_from_mail do |mail| + # mail.to "David Heinemeier Hansson " + # mail.from "Bilbo Baggins " + # mail.subject "Come down to the Shire!" + # + # mail.text_part do |part| + # part.body "Please join us for a party at Bag End" + # end + # + # mail.html_part do |part| + # part.body "

Please join us for a party at Bag End

" + # end + # end + def create_inbound_email_from_mail(status: :processing, **mail_options, &block) + mail = Mail.new(mail_options, &block) + # Bcc header is not encoded by default + mail[:bcc].include_in_headers = true if mail[:bcc] + + create_inbound_email_from_source mail.to_s, status: status + end + + # Create an InboundEmail using the raw rfc822 +source+ as text. + def create_inbound_email_from_source(source, status: :processing) + ActionMailbox::InboundEmail.create_and_extract_message_id! source, status: status + end + + + # Create an InboundEmail from fixture using the same arguments as create_inbound_email_from_fixture + # and immediately route it to processing. + def receive_inbound_email_from_fixture(*args) + create_inbound_email_from_fixture(*args).tap(&:route) + end + + # Create an InboundEmail using the same options or block as + # create_inbound_email_from_mail, then immediately route it for processing. + def receive_inbound_email_from_mail(**kwargs, &block) + create_inbound_email_from_mail(**kwargs, &block).tap(&:route) + end + + # Create an InboundEmail using the same arguments as create_inbound_email_from_source and immediately route it + # to processing. + def receive_inbound_email_from_source(*args) + create_inbound_email_from_source(*args).tap(&:route) + end + end +end diff --git a/actionmailbox/lib/action_mailbox/version.rb b/actionmailbox/lib/action_mailbox/version.rb new file mode 100644 index 0000000000000..10c4cee6adcf7 --- /dev/null +++ b/actionmailbox/lib/action_mailbox/version.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "gem_version" + +module ActionMailbox + # Returns the currently loaded version of Action Mailbox as a +Gem::Version+. + def self.version + gem_version + end +end diff --git a/actionmailbox/lib/generators/action_mailbox/install/install_generator.rb b/actionmailbox/lib/generators/action_mailbox/install/install_generator.rb new file mode 100644 index 0000000000000..05066d6071d2c --- /dev/null +++ b/actionmailbox/lib/generators/action_mailbox/install/install_generator.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails/generators/mailbox/mailbox_generator" + +module ActionMailbox + module Generators + class InstallGenerator < ::Rails::Generators::Base + source_root Rails::Generators::MailboxGenerator.source_root + + def create_action_mailbox_files + say "Copying application_mailbox.rb to app/mailboxes", :green + template "application_mailbox.rb", "app/mailboxes/application_mailbox.rb" + end + + def add_action_mailbox_production_environment_config + environment <<~end_of_config, env: "production" + # Prepare the ingress controller used to receive mail + # config.action_mailbox.ingress = :relay + + end_of_config + end + + def create_migrations + rails_command "railties:install:migrations FROM=active_storage,action_mailbox", inline: true + end + end + end +end diff --git a/actionmailbox/lib/rails/generators/mailbox/USAGE b/actionmailbox/lib/rails/generators/mailbox/USAGE new file mode 100644 index 0000000000000..371fc2af69119 --- /dev/null +++ b/actionmailbox/lib/rails/generators/mailbox/USAGE @@ -0,0 +1,10 @@ +Description: + Generates a new mailbox class in app/mailboxes and invokes your template + engine and test framework generators. + +Example: + `bin/rails generate mailbox inbox` + + creates an InboxMailbox class and test: + Mailbox: app/mailboxes/inbox_mailbox.rb + Test: test/mailboxes/inbox_mailbox_test.rb diff --git a/actionmailbox/lib/rails/generators/mailbox/mailbox_generator.rb b/actionmailbox/lib/rails/generators/mailbox/mailbox_generator.rb new file mode 100644 index 0000000000000..c2c403b8f6e94 --- /dev/null +++ b/actionmailbox/lib/rails/generators/mailbox/mailbox_generator.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Rails + module Generators + class MailboxGenerator < NamedBase + source_root File.expand_path("templates", __dir__) + + check_class_collision suffix: "Mailbox" + + def create_mailbox_file + template "mailbox.rb", File.join("app/mailboxes", class_path, "#{file_name}_mailbox.rb") + + in_root do + if behavior == :invoke && !File.exist?(application_mailbox_file_name) + template "application_mailbox.rb", application_mailbox_file_name + end + end + end + + hook_for :test_framework + + private + def file_name # :doc: + @_file_name ||= super.sub(/_mailbox\z/i, "") + end + + def application_mailbox_file_name + "app/mailboxes/application_mailbox.rb" + end + end + end +end diff --git a/actionmailbox/lib/rails/generators/mailbox/templates/application_mailbox.rb.tt b/actionmailbox/lib/rails/generators/mailbox/templates/application_mailbox.rb.tt new file mode 100644 index 0000000000000..ac22d03cd29ac --- /dev/null +++ b/actionmailbox/lib/rails/generators/mailbox/templates/application_mailbox.rb.tt @@ -0,0 +1,3 @@ +class ApplicationMailbox < ActionMailbox::Base + # routing /something/i => :somewhere +end diff --git a/actionmailbox/lib/rails/generators/mailbox/templates/mailbox.rb.tt b/actionmailbox/lib/rails/generators/mailbox/templates/mailbox.rb.tt new file mode 100644 index 0000000000000..110b3b9d7e17d --- /dev/null +++ b/actionmailbox/lib/rails/generators/mailbox/templates/mailbox.rb.tt @@ -0,0 +1,4 @@ +class <%= class_name %>Mailbox < ApplicationMailbox + def process + end +end diff --git a/actionmailbox/lib/rails/generators/test_unit/mailbox_generator.rb b/actionmailbox/lib/rails/generators/test_unit/mailbox_generator.rb new file mode 100644 index 0000000000000..2ec7d11a2fa07 --- /dev/null +++ b/actionmailbox/lib/rails/generators/test_unit/mailbox_generator.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module TestUnit + module Generators + class MailboxGenerator < ::Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + check_class_collision suffix: "MailboxTest" + + def create_test_files + template "mailbox_test.rb", File.join("test/mailboxes", class_path, "#{file_name}_mailbox_test.rb") + end + + private + def file_name # :doc: + @_file_name ||= super.sub(/_mailbox\z/i, "") + end + end + end +end diff --git a/actionmailbox/lib/rails/generators/test_unit/templates/mailbox_test.rb.tt b/actionmailbox/lib/rails/generators/test_unit/templates/mailbox_test.rb.tt new file mode 100644 index 0000000000000..3e215b4d0bed7 --- /dev/null +++ b/actionmailbox/lib/rails/generators/test_unit/templates/mailbox_test.rb.tt @@ -0,0 +1,11 @@ +require "test_helper" + +class <%= class_name %>MailboxTest < ActionMailbox::TestCase + # test "receive mail" do + # receive_inbound_email_from_mail \ + # to: '"someone" ', + # from: '"else" ', + # subject: "Hello world!", + # body: "Hello?" + # end +end diff --git a/actionmailbox/lib/tasks/ingress.rake b/actionmailbox/lib/tasks/ingress.rake new file mode 100644 index 0000000000000..43b613ea12d7d --- /dev/null +++ b/actionmailbox/lib/tasks/ingress.rake @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +namespace :action_mailbox do + namespace :ingress do + task :environment do + require "active_support" + require "active_support/core_ext/object/blank" + require "action_mailbox/relayer" + end + + desc "Relay an inbound email from Exim to Action Mailbox (URL and INGRESS_PASSWORD required)" + task exim: "action_mailbox:ingress:environment" do + url, password = ENV.values_at("URL", "INGRESS_PASSWORD") + + if url.blank? || password.blank? + print "URL and INGRESS_PASSWORD are required" + exit 64 # EX_USAGE + end + + ActionMailbox::Relayer.new(url: url, password: password).relay(STDIN.read).tap do |result| + print result.message + + case + when result.success? + exit 0 + when result.transient_failure? + exit 75 # EX_TEMPFAIL + else + exit 69 # EX_UNAVAILABLE + end + end + end + + desc "Relay an inbound email from Postfix to Action Mailbox (URL and INGRESS_PASSWORD required)" + task postfix: "action_mailbox:ingress:environment" do + url, password = ENV.values_at("URL", "INGRESS_PASSWORD") + + if url.blank? || password.blank? + print "4.3.5 URL and INGRESS_PASSWORD are required" + exit 1 + end + + ActionMailbox::Relayer.new(url: url, password: password).relay(STDIN.read).tap do |result| + print "#{result.status_code} #{result.message}" + exit result.success? + end + end + + desc "Relay an inbound email from Qmail to Action Mailbox (URL and INGRESS_PASSWORD required)" + task qmail: "action_mailbox:ingress:environment" do + url, password = ENV.values_at("URL", "INGRESS_PASSWORD") + + if url.blank? || password.blank? + print "URL and INGRESS_PASSWORD are required" + exit 111 + end + + ActionMailbox::Relayer.new(url: url, password: password).relay(STDIN.read).tap do |result| + print result.message + + case + when result.success? + exit 0 + when result.transient_failure? + exit 111 + else + exit 100 + end + end + end + end +end diff --git a/actionmailbox/lib/tasks/install.rake b/actionmailbox/lib/tasks/install.rake new file mode 100644 index 0000000000000..cffa6c9c0f431 --- /dev/null +++ b/actionmailbox/lib/tasks/install.rake @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +desc "Install Action Mailbox and its dependencies" +task "action_mailbox:install" do + Rails::Command.invoke :generate, ["action_mailbox:install"] +end diff --git a/actionmailbox/test/controllers/ingresses/mailgun/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/ingresses/mailgun/inbound_emails_controller_test.rb new file mode 100644 index 0000000000000..dfed9fa63c465 --- /dev/null +++ b/actionmailbox/test/controllers/ingresses/mailgun/inbound_emails_controller_test.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "test_helper" + +ENV["MAILGUN_INGRESS_SIGNING_KEY"] = "tbsy84uSV1Kt3ZJZELY2TmShPRs91E3yL4tzf96297vBCkDWgL" + +class ActionMailbox::Ingresses::Mailgun::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup { ActionMailbox.ingress = :mailgun } + + test "receiving an inbound email from Mailgun" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "receiving an inbound email from Mailgun with non UTF-8 characters" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/invalid_utf.eml").read + } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/invalid_utf.eml").binread, inbound_email.raw_email.download + assert_equal "05988AA6EC0D44318855A5E39E3B6F9E@jansterba.com", inbound_email.message_id + end + + test "add X-Original-To to email from Mailgun" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read, + recipient: "replies@example.com" + } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + mail = Mail.from_source(inbound_email.raw_email.download) + assert_equal "replies@example.com", mail.header["X-Original-To"].decoded + end + + test "rejecting a delayed inbound email from Mailgun" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + travel_to "2018-10-09 15:26:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + + assert_response :unauthorized + end + + test "rejecting a forged inbound email from Mailgun" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "Zx8mJBiGmiiyyfWnho3zKyjCg2pxLARoCuBM7X9AKCioShGiMX", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + + assert_response :unauthorized + end + + test "raising when the configured Mailgun Signing key is nil" do + switch_key_to nil do + assert_raises ArgumentError do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + end + end + + test "raising when the configured Mailgun Signing key is blank" do + switch_key_to "" do + assert_raises ArgumentError do + travel_to "2018-10-09 15:15:00 EDT" + post rails_mailgun_inbound_emails_url, params: { + timestamp: 1539112500, + token: "7VwW7k6Ak7zcTwoSoNm7aTtbk1g67MKAnsYLfUB7PdszbgR5Xi", + signature: "ef24c5225322217bb065b80bb54eb4f9206d764e3e16abab07f0a64d1cf477cc", + "body-mime" => file_fixture("../files/welcome.eml").read + } + end + end + end + + private + def switch_key_to(new_key) + previous_key, ENV["MAILGUN_INGRESS_SIGNING_KEY"] = ENV["MAILGUN_INGRESS_SIGNING_KEY"], new_key + yield + ensure + ENV["MAILGUN_INGRESS_SIGNING_KEY"] = previous_key + end +end diff --git a/actionmailbox/test/controllers/ingresses/mandrill/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/ingresses/mandrill/inbound_emails_controller_test.rb new file mode 100644 index 0000000000000..7dc900aa68c46 --- /dev/null +++ b/actionmailbox/test/controllers/ingresses/mandrill/inbound_emails_controller_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "test_helper" + +ENV["MANDRILL_INGRESS_API_KEY"] = "1l9Qf7lutEf7h73VXfBwhw" + +class ActionMailbox::Ingresses::Mandrill::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup do + ActionMailbox.ingress = :mandrill + @events = JSON.generate([{ event: "inbound", msg: { raw_msg: file_fixture("../files/welcome.eml").read } }]) + end + + test "verifying existence of Mandrill inbound route" do + # Mandrill uses a HEAD request to verify if a URL exists before creating the ingress webhook + head rails_mandrill_inbound_health_check_url + assert_response :ok + end + + test "receiving an inbound email from Mandrill" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_mandrill_inbound_emails_url, + headers: { "X-Mandrill-Signature" => "1bNbyqkMFL4VYIT5+RQCrPs/mRY=" }, params: { mandrill_events: @events } + end + + assert_response :ok + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "rejecting a forged inbound email from Mandrill" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_mandrill_inbound_emails_url, + headers: { "X-Mandrill-Signature" => "forged" }, params: { mandrill_events: @events } + end + + assert_response :unauthorized + end + + test "raising when Mandrill API key is nil" do + switch_key_to nil do + assert_raises ArgumentError do + post rails_mandrill_inbound_emails_url, + headers: { "X-Mandrill-Signature" => "gldscd2tAb/G+DmpiLcwukkLrC4=" }, params: { mandrill_events: @events } + end + end + end + + test "raising when Mandrill API key is blank" do + switch_key_to "" do + assert_raises ArgumentError do + post rails_mandrill_inbound_emails_url, + headers: { "X-Mandrill-Signature" => "gldscd2tAb/G+DmpiLcwukkLrC4=" }, params: { mandrill_events: @events } + end + end + end + + private + def switch_key_to(new_key) + previous_key, ENV["MANDRILL_INGRESS_API_KEY"] = ENV["MANDRILL_INGRESS_API_KEY"], new_key + yield + ensure + ENV["MANDRILL_INGRESS_API_KEY"] = previous_key + end +end diff --git a/actionmailbox/test/controllers/ingresses/postmark/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/ingresses/postmark/inbound_emails_controller_test.rb new file mode 100644 index 0000000000000..c371ad0cbae80 --- /dev/null +++ b/actionmailbox/test/controllers/ingresses/postmark/inbound_emails_controller_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionMailbox::Ingresses::Postmark::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup { ActionMailbox.ingress = :postmark } + + test "receiving an inbound email from Postmark" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_postmark_inbound_emails_url, + headers: { authorization: credentials }, params: { RawEmail: file_fixture("../files/welcome.eml").read } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "receiving an inbound email from Postmark with non UTF-8 characters" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_postmark_inbound_emails_url, + headers: { authorization: credentials }, params: { RawEmail: file_fixture("../files/invalid_utf.eml").read } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/invalid_utf.eml").binread, inbound_email.raw_email.download + assert_equal "05988AA6EC0D44318855A5E39E3B6F9E@jansterba.com", inbound_email.message_id + end + + test "add X-Original-To to email from Postmark" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_postmark_inbound_emails_url, + headers: { authorization: credentials }, params: { + RawEmail: file_fixture("../files/welcome.eml").read, + OriginalRecipient: "thisguy@domain.abcd", + } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + mail = Mail.from_source(inbound_email.raw_email.download) + assert_equal "thisguy@domain.abcd", mail.header["X-Original-To"].decoded + end + + test "rejecting when RawEmail param is missing" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_postmark_inbound_emails_url, + headers: { authorization: credentials }, params: { From: "someone@example.com" } + end + + assert_response :unprocessable_entity + end + + test "rejecting an unauthorized inbound email from Postmark" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_postmark_inbound_emails_url, params: { RawEmail: file_fixture("../files/welcome.eml").read } + end + + assert_response :unauthorized + end + + test "raising when the configured password is nil" do + switch_password_to nil do + assert_raises ArgumentError do + post rails_postmark_inbound_emails_url, + headers: { authorization: credentials }, params: { RawEmail: file_fixture("../files/welcome.eml").read } + end + end + end + + test "raising when the configured password is blank" do + switch_password_to "" do + assert_raises ArgumentError do + post rails_postmark_inbound_emails_url, + headers: { authorization: credentials }, params: { RawEmail: file_fixture("../files/welcome.eml").read } + end + end + end +end diff --git a/actionmailbox/test/controllers/ingresses/relay/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/ingresses/relay/inbound_emails_controller_test.rb new file mode 100644 index 0000000000000..ac0a6fab75093 --- /dev/null +++ b/actionmailbox/test/controllers/ingresses/relay/inbound_emails_controller_test.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionMailbox::Ingresses::Relay::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup { ActionMailbox.ingress = :relay } + + test "receiving an inbound email relayed from an SMTP server" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/welcome.eml").read + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "receiving an inbound email relayed from an SMTP server with non UTF-8 characters" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/invalid_utf.eml").read + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/invalid_utf.eml").binread, inbound_email.raw_email.download + assert_equal "05988AA6EC0D44318855A5E39E3B6F9E@jansterba.com", inbound_email.message_id + end + + test "rejecting a request with no body" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + env: { "rack.input" => nil } + end + + assert_response :unprocessable_entity + end + + test "rejecting an unauthorized inbound email" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_relay_inbound_emails_url, headers: { "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/welcome.eml").read + end + + assert_response :unauthorized + end + + test "rejecting an inbound email of an unsupported media type" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "text/plain" }, + params: file_fixture("../files/welcome.eml").read + end + + assert_response :unsupported_media_type + end + + test "raising when the configured password is nil" do + switch_password_to nil do + assert_raises ArgumentError do + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/welcome.eml").read + end + end + end + + test "raising when the configured password is blank" do + switch_password_to "" do + assert_raises ArgumentError do + post rails_relay_inbound_emails_url, headers: { "Authorization" => credentials, "Content-Type" => "message/rfc822" }, + params: file_fixture("../files/welcome.eml").read + end + end + end +end diff --git a/actionmailbox/test/controllers/ingresses/sendgrid/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/ingresses/sendgrid/inbound_emails_controller_test.rb new file mode 100644 index 0000000000000..3454585088e91 --- /dev/null +++ b/actionmailbox/test/controllers/ingresses/sendgrid/inbound_emails_controller_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionMailbox::Ingresses::Sendgrid::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + setup { ActionMailbox.ingress = :sendgrid } + + test "receiving an inbound email from Sendgrid" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_sendgrid_inbound_emails_url, + headers: { authorization: credentials }, params: { email: file_fixture("../files/welcome.eml").read } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/welcome.eml").read, inbound_email.raw_email.download + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "receiving an inbound email from Sendgrid with non UTF-8 characters" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_sendgrid_inbound_emails_url, + headers: { authorization: credentials }, params: { email: file_fixture("../files/invalid_utf.eml").read } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + assert_equal file_fixture("../files/invalid_utf.eml").binread, inbound_email.raw_email.download + assert_equal "05988AA6EC0D44318855A5E39E3B6F9E@jansterba.com", inbound_email.message_id + end + + test "add X-Original-To to email from Sendgrid" do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_sendgrid_inbound_emails_url, + headers: { authorization: credentials }, params: { + email: file_fixture("../files/welcome.eml").read, + envelope: "{\"to\":[\"replies@example.com\"],\"from\":\"jason@37signals.com\"}", + } + end + + assert_response :no_content + + inbound_email = ActionMailbox::InboundEmail.last + mail = Mail.from_source(inbound_email.raw_email.download) + assert_equal "replies@example.com", mail.header["X-Original-To"].decoded + end + + test "rejecting an unauthorized inbound email from Sendgrid" do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + post rails_sendgrid_inbound_emails_url, params: { email: file_fixture("../files/welcome.eml").read } + end + + assert_response :unauthorized + end + + test "raising when the configured password is nil" do + switch_password_to nil do + assert_raises ArgumentError do + post rails_sendgrid_inbound_emails_url, + headers: { authorization: credentials }, params: { email: file_fixture("../files/welcome.eml").read } + end + end + end + + test "raising when the configured password is blank" do + switch_password_to "" do + assert_raises ArgumentError do + post rails_sendgrid_inbound_emails_url, + headers: { authorization: credentials }, params: { email: file_fixture("../files/welcome.eml").read } + end + end + end +end diff --git a/actionmailbox/test/controllers/rails/action_mailbox/inbound_emails_controller_test.rb b/actionmailbox/test/controllers/rails/action_mailbox/inbound_emails_controller_test.rb new file mode 100644 index 0000000000000..83c9e90dd5671 --- /dev/null +++ b/actionmailbox/test/controllers/rails/action_mailbox/inbound_emails_controller_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "test_helper" + +class Rails::Conductor::ActionMailbox::InboundEmailsControllerTest < ActionDispatch::IntegrationTest + test "create inbound email" do + with_rails_env("development") do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_conductor_inbound_emails_path, params: { + mail: { + from: "Jason Fried ", + to: "Replies ", + cc: "CC ", + bcc: "Bcc ", + in_reply_to: "<4e6e35f5a38b4_479f13bb90078178@small-app-01.mail>", + subject: "Hey there", + body: "How's it going?" + } + } + end + + mail = ActionMailbox::InboundEmail.last.mail + assert_equal %w[ jason@37signals.com ], mail.from + assert_equal %w[ replies@example.com ], mail.to + assert_equal %w[ cc@example.com ], mail.cc + assert_equal %w[ bcc@example.com ], mail.bcc + assert_equal "4e6e35f5a38b4_479f13bb90078178@small-app-01.mail", mail.in_reply_to + assert_equal "Hey there", mail.subject + assert_equal "How's it going?", mail.body.decoded + end + end + + test "create inbound email with bcc" do + with_rails_env("development") do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_conductor_inbound_emails_path, params: { + mail: { + from: "Jason Fried ", + bcc: "Replies ", + subject: "Hey there", + body: "How's it going?" + } + } + end + + mail = ActionMailbox::InboundEmail.last.mail + assert_equal %w[ jason@37signals.com ], mail.from + assert_equal %w[ replies@example.com ], mail.bcc + assert_equal "Hey there", mail.subject + assert_equal "How's it going?", mail.body.decoded + end + end + + test "create inbound email with attachments" do + with_rails_env("development") do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_conductor_inbound_emails_path, params: { + mail: { + from: "Jason Fried ", + to: "Replies ", + subject: "Let's debate some attachments", + body: "Let's talk about these images:", + attachments: [ fixture_file_upload("avatar1.jpeg"), fixture_file_upload("avatar2.jpeg") ] + } + } + end + + mail = ActionMailbox::InboundEmail.last.mail + assert_equal "Let's talk about these images:", mail.text_part.decoded + assert_equal 2, mail.attachments.count + assert_equal %w[ avatar1.jpeg avatar2.jpeg ], mail.attachments.collect(&:filename) + end + end + + test "create inbound email with empty attachment" do + with_rails_env("development") do + assert_difference -> { ActionMailbox::InboundEmail.count }, +1 do + post rails_conductor_inbound_emails_path, params: { + mail: { + from: "", + to: "", + cc: "", + bcc: "", + x_original_to: "", + subject: "", + in_reply_to: "", + body: "", + attachments: [ "" ], + } + } + end + + mail = ActionMailbox::InboundEmail.last.mail + assert_equal 0, mail.attachments.count + end + end + + private + def with_rails_env(env) + old_rails_env = Rails.env + Rails.env = env + yield + ensure + Rails.env = old_rails_env + end +end diff --git a/actionmailbox/test/dummy/Rakefile b/actionmailbox/test/dummy/Rakefile new file mode 100644 index 0000000000000..9a5ea7383aa83 --- /dev/null +++ b/actionmailbox/test/dummy/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/actionmailbox/test/dummy/app/assets/config/manifest.js b/actionmailbox/test/dummy/app/assets/config/manifest.js new file mode 100644 index 0000000000000..591819335f0b2 --- /dev/null +++ b/actionmailbox/test/dummy/app/assets/config/manifest.js @@ -0,0 +1,2 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css diff --git a/actionpack/test/fixtures/layout_tests/alt/layouts/alt.erb b/actionmailbox/test/dummy/app/assets/images/.keep similarity index 100% rename from actionpack/test/fixtures/layout_tests/alt/layouts/alt.erb rename to actionmailbox/test/dummy/app/assets/images/.keep diff --git a/actionmailbox/test/dummy/app/assets/stylesheets/application.css b/actionmailbox/test/dummy/app/assets/stylesheets/application.css new file mode 100644 index 0000000000000..0ebd7fe8299eb --- /dev/null +++ b/actionmailbox/test/dummy/app/assets/stylesheets/application.css @@ -0,0 +1,15 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, + * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the bottom of the + * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS + * files in this directory. Styles in this file should be added after the last require_* statement. + * It is generally better to create a new file per style scope. + * + *= require_tree . + *= require_self + */ diff --git a/actionmailbox/test/dummy/app/assets/stylesheets/scaffold.css b/actionmailbox/test/dummy/app/assets/stylesheets/scaffold.css new file mode 100644 index 0000000000000..cd4f3de38d1f0 --- /dev/null +++ b/actionmailbox/test/dummy/app/assets/stylesheets/scaffold.css @@ -0,0 +1,80 @@ +body { + background-color: #fff; + color: #333; + margin: 33px; +} + +body, p, ol, ul, td { + font-family: verdana, arial, helvetica, sans-serif; + font-size: 13px; + line-height: 18px; +} + +pre { + background-color: #eee; + padding: 10px; + font-size: 11px; +} + +a { + color: #000; +} + +a:visited { + color: #666; +} + +a:hover { + color: #fff; + background-color: #000; +} + +th { + padding-bottom: 5px; +} + +td { + padding: 0 5px 7px; +} + +div.field, +div.actions { + margin-bottom: 10px; +} + +#notice { + color: green; +} + +.field_with_errors { + padding: 2px; + background-color: red; + display: table; +} + +#error_explanation { + width: 450px; + border: 2px solid red; + padding: 7px 7px 0; + margin-bottom: 20px; + background-color: #f0f0f0; +} + +#error_explanation h2 { + text-align: left; + font-weight: bold; + padding: 5px 5px 5px 15px; + font-size: 12px; + margin: -7px -7px 0; + background-color: #c00; + color: #fff; +} + +#error_explanation ul li { + font-size: 12px; + list-style: square; +} + +label { + display: block; +} diff --git a/actionmailbox/test/dummy/app/channels/application_cable/channel.rb b/actionmailbox/test/dummy/app/channels/application_cable/channel.rb new file mode 100644 index 0000000000000..d67269728300b --- /dev/null +++ b/actionmailbox/test/dummy/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/actionmailbox/test/dummy/app/channels/application_cable/connection.rb b/actionmailbox/test/dummy/app/channels/application_cable/connection.rb new file mode 100644 index 0000000000000..0ff5442f476f9 --- /dev/null +++ b/actionmailbox/test/dummy/app/channels/application_cable/connection.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/actionmailbox/test/dummy/app/controllers/application_controller.rb b/actionmailbox/test/dummy/app/controllers/application_controller.rb new file mode 100644 index 0000000000000..09705d12ab4df --- /dev/null +++ b/actionmailbox/test/dummy/app/controllers/application_controller.rb @@ -0,0 +1,2 @@ +class ApplicationController < ActionController::Base +end diff --git a/actionpack/test/fixtures/sprockets/app/fonts/dir/font.ttf b/actionmailbox/test/dummy/app/controllers/concerns/.keep similarity index 100% rename from actionpack/test/fixtures/sprockets/app/fonts/dir/font.ttf rename to actionmailbox/test/dummy/app/controllers/concerns/.keep diff --git a/railties/guides/code/getting_started/app/helpers/application_helper.rb b/actionmailbox/test/dummy/app/helpers/application_helper.rb similarity index 100% rename from railties/guides/code/getting_started/app/helpers/application_helper.rb rename to actionmailbox/test/dummy/app/helpers/application_helper.rb diff --git a/actionpack/test/fixtures/sprockets/app/fonts/font.ttf b/actionmailbox/test/dummy/app/javascript/packs/application.js similarity index 100% rename from actionpack/test/fixtures/sprockets/app/fonts/font.ttf rename to actionmailbox/test/dummy/app/javascript/packs/application.js diff --git a/actionmailbox/test/dummy/app/jobs/application_job.rb b/actionmailbox/test/dummy/app/jobs/application_job.rb new file mode 100644 index 0000000000000..d394c3d106230 --- /dev/null +++ b/actionmailbox/test/dummy/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/actionmailbox/test/dummy/app/mailboxes/application_mailbox.rb b/actionmailbox/test/dummy/app/mailboxes/application_mailbox.rb new file mode 100644 index 0000000000000..be51eb363999d --- /dev/null +++ b/actionmailbox/test/dummy/app/mailboxes/application_mailbox.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationMailbox < ActionMailbox::Base + # routing /something/i => :somewhere +end diff --git a/actionmailbox/test/dummy/app/mailboxes/messages_mailbox.rb b/actionmailbox/test/dummy/app/mailboxes/messages_mailbox.rb new file mode 100644 index 0000000000000..1a046ee13d959 --- /dev/null +++ b/actionmailbox/test/dummy/app/mailboxes/messages_mailbox.rb @@ -0,0 +1,4 @@ +class MessagesMailbox < ApplicationMailbox + def process + end +end diff --git a/actionmailbox/test/dummy/app/mailers/application_mailer.rb b/actionmailbox/test/dummy/app/mailers/application_mailer.rb new file mode 100644 index 0000000000000..3c34c8148f105 --- /dev/null +++ b/actionmailbox/test/dummy/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/actionmailbox/test/dummy/app/models/application_record.rb b/actionmailbox/test/dummy/app/models/application_record.rb new file mode 100644 index 0000000000000..b63caeb8a5c4a --- /dev/null +++ b/actionmailbox/test/dummy/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/actionpack/test/fixtures/sprockets/app/javascripts/dir/xmlhr.js b/actionmailbox/test/dummy/app/models/concerns/.keep similarity index 100% rename from actionpack/test/fixtures/sprockets/app/javascripts/dir/xmlhr.js rename to actionmailbox/test/dummy/app/models/concerns/.keep diff --git a/actionmailbox/test/dummy/app/views/layouts/application.html.erb b/actionmailbox/test/dummy/app/views/layouts/application.html.erb new file mode 100644 index 0000000000000..f72b4ef0e7316 --- /dev/null +++ b/actionmailbox/test/dummy/app/views/layouts/application.html.erb @@ -0,0 +1,15 @@ + + + + Dummy + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= stylesheet_link_tag "application" %> + + + + <%= yield %> + + diff --git a/actionmailbox/test/dummy/app/views/layouts/mailer.html.erb b/actionmailbox/test/dummy/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000000000..3aac9002edca7 --- /dev/null +++ b/actionmailbox/test/dummy/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/actionpack/test/fixtures/layouts/_yield_only.erb b/actionmailbox/test/dummy/app/views/layouts/mailer.text.erb similarity index 100% rename from actionpack/test/fixtures/layouts/_yield_only.erb rename to actionmailbox/test/dummy/app/views/layouts/mailer.text.erb diff --git a/actionmailbox/test/dummy/bin/rails b/actionmailbox/test/dummy/bin/rails new file mode 100755 index 0000000000000..efc0377492f7e --- /dev/null +++ b/actionmailbox/test/dummy/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/actionmailbox/test/dummy/bin/rake b/actionmailbox/test/dummy/bin/rake new file mode 100755 index 0000000000000..4fbf10b960ef7 --- /dev/null +++ b/actionmailbox/test/dummy/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/actionmailbox/test/dummy/bin/setup b/actionmailbox/test/dummy/bin/setup new file mode 100755 index 0000000000000..3cd5a9d7801ca --- /dev/null +++ b/actionmailbox/test/dummy/bin/setup @@ -0,0 +1,33 @@ +#!/usr/bin/env ruby +require "fileutils" + +# path to your application root. +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system! "gem install bundler --conservative" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + puts "\n== Restarting application server ==" + system! "bin/rails restart" +end diff --git a/actionmailbox/test/dummy/config.ru b/actionmailbox/test/dummy/config.ru new file mode 100644 index 0000000000000..4a3c09a6889a9 --- /dev/null +++ b/actionmailbox/test/dummy/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/actionmailbox/test/dummy/config/application.rb b/actionmailbox/test/dummy/config/application.rb new file mode 100644 index 0000000000000..8154f557052d9 --- /dev/null +++ b/actionmailbox/test/dummy/config/application.rb @@ -0,0 +1,27 @@ +require_relative "boot" + +require "rails/all" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module Dummy + class Application < Rails::Application + config.load_defaults Rails::VERSION::STRING.to_f + + # For compatibility with applications that use this config + config.action_controller.include_all_helpers = false + + config.active_record.table_name_prefix = 'prefix_' + config.active_record.table_name_suffix = '_suffix' + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + end +end diff --git a/actionmailbox/test/dummy/config/boot.rb b/actionmailbox/test/dummy/config/boot.rb new file mode 100644 index 0000000000000..d5e0f0fdc80b8 --- /dev/null +++ b/actionmailbox/test/dummy/config/boot.rb @@ -0,0 +1,5 @@ +# Set up gems listed in the Gemfile. +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../../Gemfile", __dir__) + +require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) +$LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) diff --git a/actionmailbox/test/dummy/config/cable.yml b/actionmailbox/test/dummy/config/cable.yml new file mode 100644 index 0000000000000..98367f8954247 --- /dev/null +++ b/actionmailbox/test/dummy/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: async + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("http://github.com/REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: dummy_production diff --git a/actionmailbox/test/dummy/config/database.yml b/actionmailbox/test/dummy/config/database.yml new file mode 100644 index 0000000000000..796466ba23eed --- /dev/null +++ b/actionmailbox/test/dummy/config/database.yml @@ -0,0 +1,25 @@ +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" +# +default: &default + adapter: sqlite3 + pool: <%= ENV.fetch("http://github.com/RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: storage/test.sqlite3 + +production: + <<: *default + database: storage/production.sqlite3 diff --git a/actionmailbox/test/dummy/config/environment.rb b/actionmailbox/test/dummy/config/environment.rb new file mode 100644 index 0000000000000..cac5315775258 --- /dev/null +++ b/actionmailbox/test/dummy/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/actionmailbox/test/dummy/config/environments/development.rb b/actionmailbox/test/dummy/config/environments/development.rb new file mode 100644 index 0000000000000..4d134bb530348 --- /dev/null +++ b/actionmailbox/test/dummy/config/environments/development.rb @@ -0,0 +1,73 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # In the development environment your application's code is reloaded any time + # it changes. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing + config.server_timing = true + + # Enable/disable caching. By default caching is disabled. + # Run rails dev:cache to toggle caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + + config.cache_store = :memory_store + config.public_file_server.headers = { + "Cache-Control" => "public, max-age=#{2.days.to_i}" + } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + config.action_mailer.perform_caching = false + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/actionmailbox/test/dummy/config/environments/production.rb b/actionmailbox/test/dummy/config/environments/production.rb new file mode 100644 index 0000000000000..999e332a7e2be --- /dev/null +++ b/actionmailbox/test/dummy/config/environments/production.rb @@ -0,0 +1,89 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] + # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). + # config.require_master_key = true + + # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. + # config.public_file_server.enabled = false + + # Compress CSS using a preprocessor. + # config.assets.css_compressor = :sass + + # Do not fall back to assets pipeline if a precompiled asset is missed. + config.assets.compile = false + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com/" + + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache + # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Mount Action Cable outside main process or domain. + # config.action_cable.mount_path = nil + # config.action_cable.url = "wss://example.com/cable" + # config.action_cable.allowed_request_origins = [ "http://example.com/", /http:\/\/example.*/ ] + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Log to STDOUT by default + config.logger = ActiveSupport::Logger.new(STDOUT) + .tap { |logger| logger.formatter = ::Logger::Formatter.new } + .then { |logger| ActiveSupport::TaggedLogging.new(logger) } + + # Prepend all log lines with the following tags. + config.log_tags = [ :request_id ] + + # "info" includes generic and useful information about system operation, but avoids logging too much + # information to avoid inadvertent exposure of personally identifiable information (PII). Use "debug" + # for everything. + config.log_level = ENV.fetch("http://github.com/RAILS_LOG_LEVEL") { "info" } + + # Use a different cache store in production. + # config.cache_store = :mem_cache_store + + # Use a real queuing backend for Active Job (and separate queues per environment). + # config.active_job.queue_adapter = :resque + # config.active_job.queue_name_prefix = "dummy_production" + + config.action_mailer.perform_caching = false + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false +end diff --git a/actionmailbox/test/dummy/config/environments/test.rb b/actionmailbox/test/dummy/config/environments/test.rb new file mode 100644 index 0000000000000..5b1b89421f004 --- /dev/null +++ b/actionmailbox/test/dummy/config/environments/test.rb @@ -0,0 +1,63 @@ +require "active_support/core_ext/integer/time" + +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with Cache-Control for performance. + config.public_file_server.headers = { + "Cache-Control" => "public, max-age=#{1.hour.to_i}" + } + + # Show full error reports and disable caching. + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.cache_store = :null_store + + # Raise exceptions instead of rendering exception templates. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + config.action_mailer.perform_caching = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/actionmailbox/test/dummy/config/initializers/assets.rb b/actionmailbox/test/dummy/config/initializers/assets.rb new file mode 100644 index 0000000000000..2eeef966fe872 --- /dev/null +++ b/actionmailbox/test/dummy/config/initializers/assets.rb @@ -0,0 +1,12 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path + +# Precompile additional assets. +# application.js, application.css, and all non-JS/CSS in the app/assets +# folder are already added. +# Rails.application.config.assets.precompile += %w( admin.js admin.css ) diff --git a/actionmailbox/test/dummy/config/initializers/content_security_policy.rb b/actionmailbox/test/dummy/config/initializers/content_security_policy.rb new file mode 100644 index 0000000000000..b3076b38fe143 --- /dev/null +++ b/actionmailbox/test/dummy/config/initializers/content_security_policy.rb @@ -0,0 +1,25 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/actionmailbox/test/dummy/config/initializers/filter_parameter_logging.rb b/actionmailbox/test/dummy/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000000000..adc6568ce8372 --- /dev/null +++ b/actionmailbox/test/dummy/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be filtered from the log file. Use this to limit dissemination of +# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported +# notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn +] diff --git a/actionmailbox/test/dummy/config/initializers/inflections.rb b/actionmailbox/test/dummy/config/initializers/inflections.rb new file mode 100644 index 0000000000000..3860f659ead02 --- /dev/null +++ b/actionmailbox/test/dummy/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/actionmailbox/test/dummy/config/locales/en.yml b/actionmailbox/test/dummy/config/locales/en.yml new file mode 100644 index 0000000000000..6c349ae5e3743 --- /dev/null +++ b/actionmailbox/test/dummy/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/actionmailbox/test/dummy/config/puma.rb b/actionmailbox/test/dummy/config/puma.rb new file mode 100644 index 0000000000000..09a5c4b7865e7 --- /dev/null +++ b/actionmailbox/test/dummy/config/puma.rb @@ -0,0 +1,38 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. + +# Puma can serve each request in a thread from an internal thread pool. +# The `threads` method setting takes two numbers: a minimum and maximum. +# Any libraries that use thread pools should be configured to match +# the maximum value specified for Puma. Default is set to 5 threads for minimum +# and maximum; this matches the default thread size of Active Record. +max_threads_count = ENV.fetch("http://github.com/RAILS_MAX_THREADS") { 5 } +min_threads_count = ENV.fetch("http://github.com/RAILS_MIN_THREADS") { max_threads_count } +threads min_threads_count, max_threads_count + +# Specifies that the worker count should equal the number of processors in production. +if ENV["RAILS_ENV"] == "production" + worker_count = Integer(ENV.fetch("http://github.com/WEB_CONCURRENCY") { Concurrent.physical_processor_count }) + workers worker_count if worker_count > 1 +end + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +worker_timeout 3600 if ENV.fetch("http://github.com/RAILS_ENV", "development") == "development" + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("http://github.com/PORT") { 3000 } + +# Specifies the `environment` that Puma will run in. +environment ENV.fetch("http://github.com/RAILS_ENV") { "development" } + +# Specifies the `pidfile` that Puma will use. +if ENV["PIDFILE"] + pidfile ENV["PIDFILE"] +else + pidfile "tmp/pids/server.pid" if ENV.fetch("http://github.com/RAILS_ENV", "development") == "development" +end + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart diff --git a/actionmailbox/test/dummy/config/routes.rb b/actionmailbox/test/dummy/config/routes.rb new file mode 100644 index 0000000000000..1daf9a4121a8b --- /dev/null +++ b/actionmailbox/test/dummy/config/routes.rb @@ -0,0 +1,2 @@ +Rails.application.routes.draw do +end diff --git a/actionmailbox/test/dummy/config/storage.yml b/actionmailbox/test/dummy/config/storage.yml new file mode 100644 index 0000000000000..c26dd89d229af --- /dev/null +++ b/actionmailbox/test/dummy/config/storage.yml @@ -0,0 +1,38 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +test_email: + service: Disk + root: <%= Rails.root.join("tmp/storage_email") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/actionmailbox/test/dummy/db/migrate/20180208205311_create_action_mailbox_tables.rb b/actionmailbox/test/dummy/db/migrate/20180208205311_create_action_mailbox_tables.rb new file mode 100644 index 0000000000000..2a1e2f7a8e2e1 --- /dev/null +++ b/actionmailbox/test/dummy/db/migrate/20180208205311_create_action_mailbox_tables.rb @@ -0,0 +1,19 @@ +class CreateActionMailboxTables < ActiveRecord::Migration[6.0] + def change + create_table :action_mailbox_inbound_emails, id: primary_key_type do |t| + t.integer :status, default: 0, null: false + t.string :message_id, null: false + t.string :message_checksum, null: false + + t.timestamps + + t.index [ :message_id, :message_checksum ], name: "index_action_mailbox_inbound_emails_uniqueness", unique: true + end + end + + private + def primary_key_type + config = Rails.configuration.generators + config.options[config.orm][:primary_key_type] || :primary_key + end +end diff --git a/actionmailbox/test/dummy/db/migrate/20180212164506_create_active_storage_tables.active_storage.rb b/actionmailbox/test/dummy/db/migrate/20180212164506_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000000000..87798267b4764 --- /dev/null +++ b/actionmailbox/test/dummy/db/migrate/20180212164506_create_active_storage_tables.active_storage.rb @@ -0,0 +1,36 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[5.2] + def change + create_table :active_storage_blobs do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum, null: false + t.datetime :created_at, null: false + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false + t.references :blob, null: false + + t.datetime :created_at, null: false + + t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records do |t| + t.belongs_to :blob, null: false, index: false + t.string :variation_digest, null: false + + t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end +end diff --git a/actionmailbox/test/dummy/db/schema.rb b/actionmailbox/test/dummy/db/schema.rb new file mode 100644 index 0000000000000..acbc0de9d3715 --- /dev/null +++ b/actionmailbox/test/dummy/db/schema.rb @@ -0,0 +1,53 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 2018_02_12_164506) do + create_table "action_mailbox_inbound_emails", force: :cascade do |t| + t.integer "status", default: 0, null: false + t.string "message_id", null: false + t.string "message_checksum", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["message_id", "message_checksum"], name: "index_action_mailbox_inbound_emails_uniqueness", unique: true + end + + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.integer "record_id", null: false + t.integer "blob_id", null: false + t.datetime "created_at", precision: nil, null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum", null: false + t.datetime "created_at", precision: nil, null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.integer "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" +end diff --git a/actionpack/test/fixtures/sprockets/app/javascripts/extra.js b/actionmailbox/test/dummy/lib/assets/.keep similarity index 100% rename from actionpack/test/fixtures/sprockets/app/javascripts/extra.js rename to actionmailbox/test/dummy/lib/assets/.keep diff --git a/actionpack/test/fixtures/sprockets/app/javascripts/xmlhr.js b/actionmailbox/test/dummy/log/.keep similarity index 100% rename from actionpack/test/fixtures/sprockets/app/javascripts/xmlhr.js rename to actionmailbox/test/dummy/log/.keep diff --git a/actionmailbox/test/dummy/public/400.html b/actionmailbox/test/dummy/public/400.html new file mode 100644 index 0000000000000..f59c79ab82f05 --- /dev/null +++ b/actionmailbox/test/dummy/public/400.html @@ -0,0 +1,114 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/actionmailbox/test/dummy/public/404.html b/actionmailbox/test/dummy/public/404.html new file mode 100644 index 0000000000000..26d16027c6a4c --- /dev/null +++ b/actionmailbox/test/dummy/public/404.html @@ -0,0 +1,114 @@ + + + + + + + The page you were looking for doesn't exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/actionmailbox/test/dummy/public/422.html b/actionmailbox/test/dummy/public/422.html new file mode 100644 index 0000000000000..ed5a5805d0e5f --- /dev/null +++ b/actionmailbox/test/dummy/public/422.html @@ -0,0 +1,114 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/actionmailbox/test/dummy/public/500.html b/actionmailbox/test/dummy/public/500.html new file mode 100644 index 0000000000000..318723853a010 --- /dev/null +++ b/actionmailbox/test/dummy/public/500.html @@ -0,0 +1,114 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/actionpack/test/fixtures/sprockets/app/stylesheets/dir/style.css b/actionmailbox/test/dummy/public/apple-touch-icon-precomposed.png similarity index 100% rename from actionpack/test/fixtures/sprockets/app/stylesheets/dir/style.css rename to actionmailbox/test/dummy/public/apple-touch-icon-precomposed.png diff --git a/actionpack/test/fixtures/sprockets/app/stylesheets/extra.css b/actionmailbox/test/dummy/public/apple-touch-icon.png similarity index 100% rename from actionpack/test/fixtures/sprockets/app/stylesheets/extra.css rename to actionmailbox/test/dummy/public/apple-touch-icon.png diff --git a/railties/guides/code/getting_started/public/favicon.ico b/actionmailbox/test/dummy/public/favicon.ico similarity index 100% rename from railties/guides/code/getting_started/public/favicon.ico rename to actionmailbox/test/dummy/public/favicon.ico diff --git a/actionpack/test/fixtures/sprockets/app/stylesheets/style.css b/actionmailbox/test/dummy/storage/.keep similarity index 100% rename from actionpack/test/fixtures/sprockets/app/stylesheets/style.css rename to actionmailbox/test/dummy/storage/.keep diff --git a/actionmailbox/test/fixtures/files/avatar1.jpeg b/actionmailbox/test/fixtures/files/avatar1.jpeg new file mode 100644 index 0000000000000..31111c3bc9cf2 Binary files /dev/null and b/actionmailbox/test/fixtures/files/avatar1.jpeg differ diff --git a/actionmailbox/test/fixtures/files/avatar2.jpeg b/actionmailbox/test/fixtures/files/avatar2.jpeg new file mode 100644 index 0000000000000..e844e7f3c61d6 Binary files /dev/null and b/actionmailbox/test/fixtures/files/avatar2.jpeg differ diff --git a/actionmailbox/test/fixtures/files/invalid_utf.eml b/actionmailbox/test/fixtures/files/invalid_utf.eml new file mode 100644 index 0000000000000..c5c03d572b643 --- /dev/null +++ b/actionmailbox/test/fixtures/files/invalid_utf.eml @@ -0,0 +1,39 @@ +thread-index: Adjkg/rniynGRZvvRu2Ftd4zu7/YrA== +Thread-Topic: =?iso-8859-2?Q?Informace_o_skladov=FDch_z=E1sob=E1ch_Copmany?= +From: +To: , +Message-ID: <05988AA6EC0D44318855A5E39E3B6F9E@jansterba.com> +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="----=_NextPart_000_168F_01D8E494.BE7019A0" +Content-Class: urn:content-classes:message +Importance: normal +Priority: normal +X-MimeOLE: Produced By Microsoft MimeOLE V6.3.9600.20564 +X-EOPAttributedMessage: 0 +X-Spam-IndexStatus: 0 + +This is a multi-part message in MIME format. + +------=_NextPart_000_168F_01D8E494.BE7019A0 +Content-Type: multipart/alternative; + boundary="----=_NextPart_001_1690_01D8E494.BE7019A0" + +------=_NextPart_001_1690_01D8E494.BE7019A0 +Content-Type: text/plain; + charset="iso-8859-2" +Content-Transfer-Encoding: quoted-printable + +V=E1=BEen=FD z=E1kazn=EDku, + +v p=F8=EDloze zas=EDl=E1me aktu=E1ln=ED informace o skladov=FDch = +z=E1sob=E1ch. + +------=_NextPart_001_1690_01D8E494.BE7019A0 +Content-Type: text/html; + charset="iso-8859-2" +Content-Transfer-Encoding: 8bit + +V�en� z�kazn�ku,

v p��loze zas�l�me aktu�ln� informace o skladov�ch z�sob�ch. + +------=_NextPart_000_168F_01D8E494.BE7019A0-- diff --git a/actionmailbox/test/fixtures/files/welcome.eml b/actionmailbox/test/fixtures/files/welcome.eml new file mode 100644 index 0000000000000..27fd51c58a586 --- /dev/null +++ b/actionmailbox/test/fixtures/files/welcome.eml @@ -0,0 +1,631 @@ +From: Jason Fried +Mime-Version: 1.0 (Apple Message framework v1244.3) +Content-Type: multipart/alternative; boundary="Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74" +Subject: Discussion: Let's debate these attachments +Date: Tue, 13 Sep 2011 15:19:37 -0400 +In-Reply-To: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail> +To: "Replies" +References: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail> +Message-Id: <0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com> +X-Mailer: Apple Mail (2.1244.3) + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; + charset=utf-8 + +Let's talk about these images: + + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74 +Content-Type: multipart/related; + type="text/html"; + boundary="Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1" + + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1 +Content-Transfer-Encoding: base64 +Content-Disposition: inline; + filename=avatar1.jpeg +Content-Type: image/jpeg; + name="avatar1.jpeg" +Content-Id: <7AAEB353-2341-4D46-A054-5CA5CB2363B7> + +/9j/4AAQSkZJRgABAQAAAQABAAD/4gxYSUNDX1BST0ZJTEUAAQEAAAxITGlubwIQAABtbnRyUkdC +IFhZWiAHzgACAAkABgAxAABhY3NwTVNGVAAAAABJRUMgc1JHQgAAAAAAAAAAAAAAAAAA9tYAAQAA +AADTLUhQICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFj +cHJ0AAABUAAAADNkZXNjAAABhAAAAGx3dHB0AAAB8AAAABRia3B0AAACBAAAABRyWFlaAAACGAAA +ABRnWFlaAAACLAAAABRiWFlaAAACQAAAABRkbW5kAAACVAAAAHBkbWRkAAACxAAAAIh2dWVkAAAD +TAAAAIZ2aWV3AAAD1AAAACRsdW1pAAAD+AAAABRtZWFzAAAEDAAAACR0ZWNoAAAEMAAAAAxyVFJD +AAAEPAAACAxnVFJDAAAEPAAACAxiVFJDAAAEPAAACAx0ZXh0AAAAAENvcHlyaWdodCAoYykgMTk5 +OCBIZXdsZXR0LVBhY2thcmQgQ29tcGFueQAAZGVzYwAAAAAAAAASc1JHQiBJRUM2MTk2Ni0yLjEA +AAAAAAAAAAAAABJzUkdCIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAWFlaIAAAAAAAAPNRAAEAAAABFsxYWVogAAAAAAAAAAAAAAAA +AAAAAFhZWiAAAAAAAABvogAAOPUAAAOQWFlaIAAAAAAAAGKZAAC3hQAAGNpYWVogAAAAAAAAJKAA +AA+EAAC2z2Rlc2MAAAAAAAAAFklFQyBodHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAFklFQyBo +dHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAABkZXNjAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAsUmVmZXJlbmNlIFZpZXdpbmcg +Q29uZGl0aW9uIGluIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENv +bmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZpZXcAAAAA +ABOk/gAUXy4AEM8UAAPtzAAEEwsAA1yeAAAAAVhZWiAAAAAAAEwJVgBQAAAAVx/nbWVhcwAAAAAA +AAABAAAAAAAAAAAAAAAAAAAAAAAAAo8AAAACc2lnIAAAAABDUlQgY3VydgAAAAAAAAQAAAAABQAK +AA8AFAAZAB4AIwAoAC0AMgA3ADsAQABFAEoATwBUAFkAXgBjAGgAbQByAHcAfACBAIYAiwCQAJUA +mgCfAKQAqQCuALIAtwC8AMEAxgDLANAA1QDbAOAA5QDrAPAA9gD7AQEBBwENARMBGQEfASUBKwEy +ATgBPgFFAUwBUgFZAWABZwFuAXUBfAGDAYsBkgGaAaEBqQGxAbkBwQHJAdEB2QHhAekB8gH6AgMC +DAIUAh0CJgIvAjgCQQJLAlQCXQJnAnECegKEAo4CmAKiAqwCtgLBAssC1QLgAusC9QMAAwsDFgMh +Ay0DOANDA08DWgNmA3IDfgOKA5YDogOuA7oDxwPTA+AD7AP5BAYEEwQgBC0EOwRIBFUEYwRxBH4E +jASaBKgEtgTEBNME4QTwBP4FDQUcBSsFOgVJBVgFZwV3BYYFlgWmBbUFxQXVBeUF9gYGBhYGJwY3 +BkgGWQZqBnsGjAadBq8GwAbRBuMG9QcHBxkHKwc9B08HYQd0B4YHmQesB78H0gflB/gICwgfCDII +RghaCG4IggiWCKoIvgjSCOcI+wkQCSUJOglPCWQJeQmPCaQJugnPCeUJ+woRCicKPQpUCmoKgQqY +Cq4KxQrcCvMLCwsiCzkLUQtpC4ALmAuwC8gL4Qv5DBIMKgxDDFwMdQyODKcMwAzZDPMNDQ0mDUAN +Wg10DY4NqQ3DDd4N+A4TDi4OSQ5kDn8Omw62DtIO7g8JDyUPQQ9eD3oPlg+zD88P7BAJECYQQxBh +EH4QmxC5ENcQ9RETETERTxFtEYwRqhHJEegSBxImEkUSZBKEEqMSwxLjEwMTIxNDE2MTgxOkE8UT +5RQGFCcUSRRqFIsUrRTOFPAVEhU0FVYVeBWbFb0V4BYDFiYWSRZsFo8WshbWFvoXHRdBF2UXiReu +F9IX9xgbGEAYZRiKGK8Y1Rj6GSAZRRlrGZEZtxndGgQaKhpRGncanhrFGuwbFBs7G2MbihuyG9oc +AhwqHFIcexyjHMwc9R0eHUcdcB2ZHcMd7B4WHkAeah6UHr4e6R8THz4faR+UH78f6iAVIEEgbCCY +IMQg8CEcIUghdSGhIc4h+yInIlUigiKvIt0jCiM4I2YjlCPCI/AkHyRNJHwkqyTaJQklOCVoJZcl +xyX3JicmVyaHJrcm6CcYJ0kneierJ9woDSg/KHEooijUKQYpOClrKZ0p0CoCKjUqaCqbKs8rAis2 +K2krnSvRLAUsOSxuLKIs1y0MLUEtdi2rLeEuFi5MLoIuty7uLyQvWi+RL8cv/jA1MGwwpDDbMRIx +SjGCMbox8jIqMmMymzLUMw0zRjN/M7gz8TQrNGU0njTYNRM1TTWHNcI1/TY3NnI2rjbpNyQ3YDec +N9c4FDhQOIw4yDkFOUI5fzm8Ofk6Njp0OrI67zstO2s7qjvoPCc8ZTykPOM9Ij1hPaE94D4gPmA+ +oD7gPyE/YT+iP+JAI0BkQKZA50EpQWpBrEHuQjBCckK1QvdDOkN9Q8BEA0RHRIpEzkUSRVVFmkXe +RiJGZ0arRvBHNUd7R8BIBUhLSJFI10kdSWNJqUnwSjdKfUrESwxLU0uaS+JMKkxyTLpNAk1KTZNN +3E4lTm5Ot08AT0lPk0/dUCdQcVC7UQZRUFGbUeZSMVJ8UsdTE1NfU6pT9lRCVI9U21UoVXVVwlYP +VlxWqVb3V0RXklfgWC9YfVjLWRpZaVm4WgdaVlqmWvVbRVuVW+VcNVyGXNZdJ114XcleGl5sXr1f +D19hX7NgBWBXYKpg/GFPYaJh9WJJYpxi8GNDY5dj62RAZJRk6WU9ZZJl52Y9ZpJm6Gc9Z5Nn6Wg/ +aJZo7GlDaZpp8WpIap9q92tPa6dr/2xXbK9tCG1gbbluEm5rbsRvHm94b9FwK3CGcOBxOnGVcfBy +S3KmcwFzXXO4dBR0cHTMdSh1hXXhdj52m3b4d1Z3s3gReG54zHkqeYl553pGeqV7BHtje8J8IXyB +fOF9QX2hfgF+Yn7CfyN/hH/lgEeAqIEKgWuBzYIwgpKC9INXg7qEHYSAhOOFR4Wrhg6GcobXhzuH +n4gEiGmIzokziZmJ/opkisqLMIuWi/yMY4zKjTGNmI3/jmaOzo82j56QBpBukNaRP5GokhGSepLj +k02TtpQglIqU9JVflcmWNJaflwqXdZfgmEyYuJkkmZCZ/JpomtWbQpuvnByciZz3nWSd0p5Anq6f +HZ+Ln/qgaaDYoUehtqImopajBqN2o+akVqTHpTilqaYapoum/adup+CoUqjEqTepqaocqo+rAqt1 +q+msXKzQrUStuK4trqGvFq+LsACwdbDqsWCx1rJLssKzOLOutCW0nLUTtYq2AbZ5tvC3aLfguFm4 +0blKucK6O7q1uy67p7whvJu9Fb2Pvgq+hL7/v3q/9cBwwOzBZ8Hjwl/C28NYw9TEUcTOxUvFyMZG +xsPHQce/yD3IvMk6ybnKOMq3yzbLtsw1zLXNNc21zjbOts83z7jQOdC60TzRvtI/0sHTRNPG1EnU +y9VO1dHWVdbY11zX4Nhk2OjZbNnx2nba+9uA3AXcit0Q3ZbeHN6i3ynfr+A24L3hROHM4lPi2+Nj +4+vkc+T85YTmDeaW5x/nqegy6LzpRunQ6lvq5etw6/vshu0R7ZzuKO6070DvzPBY8OXxcvH/8ozz +GfOn9DT0wvVQ9d72bfb794r4Gfio+Tj5x/pX+uf7d/wH/Jj9Kf26/kv+3P9t////2wBDAAICAgIC +AQICAgICAgIDAwYEAwMDAwcFBQQGCAcICAgHCAgJCg0LCQkMCggICw8LDA0ODg4OCQsQEQ8OEQ0O +Dg7/2wBDAQICAgMDAwYEBAYOCQgJDg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4O +Dg4ODg4ODg4ODg4ODg7/wAARCADwAPADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAEC +AwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0Kx +wRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1 +dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ +2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QA +tREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk +NOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaH +iImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq +8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD9v1Wob5cWEh5q4v3qhvlzp0gz2oA+XvEwiTXbtWwTuJ59 +6/Mn4tCGP9p+OabLR5UEKeB81fo345uPK8Y3lvnkj86/M341XaW3xuSfjYeWz3IPFAHv+r6mINN0 +LdLt3na+Bnj6nmvtn4ISiT4eaeN2VVSAfXrX583Eiah4L8PrCgeVmGZT2yucV90fAZnTwLbiQ/vQ +SKAPrjTseWMVsL0rhdU8UaF4R8E3/iLxJqUGkaLYw+Zd3UxwqD8OSScDA55r4n8Yftla1r0l/bfC +rwxcQaWG2w6zrVs0YkyR8wQ4IVgTtJGTQB+iEl1awRk3FxFEoGSXcKB78kV5n4m+MHwu0W5TStY8 +c+GbS/mDlbd75C20dWO0kjFfiD8Y/ib8V9e1Kzmu/Ef9owLcx3U9pZ6o8TSIzlGgAyAQvXH+1XkX +iP4ca0uq3ev6fJHo2nXOnTCeVSX8iKVlQfvDncVYqrjrhgegoA+xfi9J+zR4ul8RfEZ/iO/2G5u2 +imtYLDfdCYHaDEpwQrY9ORXhej/DT4A61q2oahonxIkdbexa7vYdV0do4lj3KkgV93Ubs4+pr4ck +8F+LLmaz068WKwuWl2hJnIFwFTd8vcDaAQehwa5PWfE15HomnaQt1NpMls6xsshDCRxv2yAqM4wS +Dj1oA/c/9mz4O2Pwt+OGpeJ/D9/4e17wvqaobHU9OvUm2wkE4bPKnPav1XsJ1n0xWVw+ByQfbP8A +Wv4/tB+N/jHwHbKPDGuXaRXOyS5tIZCsSspPzIeBj2PNfU3w2/4KK+NfC3jjT73UL3UbnThax2dw +s0rSb23sTcFehbaQuP8AYoA/psHRfxplwhe1IHPB4r81Pgf+394d8ZR6g3ia7tJ7CC4X/TreJ4sR +FQNxRhuyGznAxzX6SaNq+l6/osGpaVeQX1lOgaOWI8MD0oA+SfjX4S8Rz3yX9pcB9PLbJY8cqCOu +fxr4J8ZeAr6y1YbJ237+cnOa/YfxtZRT+E7oMoI8tsHNfnX8Q7i2ttfjMwjVdvJPrmgDrf2UPhdb +vfap4q1PM96lwILTP8AwCx/Wv0UtoPs8CoDkAcV8q/sx6jb3nw4uli+8l64fjHOBX1sBx1BoAjC8 +Uu3PHpUoXj/69L0oAwNZ02C50e4WVN4dcN7jHSvgPxR4b03S/FGrQrbeUBOzqM4wCOBjvX6H3p/0 +F8gkbT0FfnH8d9e/sLx1fGSC5hjkAw7IQrHDd6ALPw9ttNj+K2jzMka4uNqZ+9nFfonZL/oEfPav +x7+Gev8AiDxP8YtLXw9ps+pXVvcrLNGH2oiZxuJ+nbrX6+6OZjo0AnULJs+bFAGnTgMU6igA7V81 +fHG8srDRLWa5kiTbNxu6/dNfSh5U151488D6R4t8OT22p2a3MRXj1U460AX1B80U26QnTpR3xVlV +4x39ajuQTZyYGaAPjT4nosXjGeXG1sYQj1r8u/jq+34rQzNgAHDhugz6V+qXxYg/4qhwOAw4NfmB +8e7Zk8dWqlR8/V2XIHpQB6b4Uxd/DPSIRMWLMHDKPbpX1T4U+JPg74VfCsav441eDRY2ceTE0bGS +VicAIO5JwK+LLDxloPgfwHo+i6xLNJ4mmKyWljaL5hmQ92C9Fr6j+DX7OF/8afHlp8TPiLHfw+Hh +KH0zSrp9yKi9BtI+XnmgDfu/D/jT9pXWLHUNVguLLwjHcpcaVpkTsINqnlrg/wAbnghegr2vw9+z +TpHhnTpILxm1ppJzcKbvDorduP7o6AV9g6Zoum6Lo8NjplnDa2sSbEjjQKBirMkAkYbwuPpmgD8a +/jx+zvf6ppUy3dxp+kWVjKj6febREFk3Z2lgOFI6+4r5q0eP4p+HPi3caBDp9t4u0a5sJ7q50CZU +P2YpGFMgJ/5ZnCsMZzX9A2t+GNK1rSJrK/topoJVZGBQelfF/j/9mG1l+IGn+LtKudSi1exiaG2k +trgoAmDjev8AEP4SO4JoA/Jn4m22kReCfDWtabpuqaNf6jDHca4uopi12qCQ0DDn5gSMDtmvhXUd +Klt/FkkxZ4nW28+1Mluf3SqcBVz13Zzk1+wvxq/Z08QWvgm0t9Age0tjeiSexsZz5NsrqRIsCvzs +blmX+E4xxXx/rHwB8ceIYtXWSwvnksdtnYwYO+FCOPlxkDHPNAH58apJfzeIDbugaNQ3mb1wVz06 +VNo+hXd9enT0i8+Vx9yEZI9zX3p4J/Y18Za7dPealGYAB5ayyRk9OpAxzn+lfaHw6/Yw8K+GjDdX +0dxM+P3m9fnYkc8HpQB+afhT4KeO9Z8E2S6Ok1rLMrRho8KyRHHJwfUV9X/D74+fEv8AZ/8AEtho +vju/8V6Jp9nai2XU4ZfOjuGByMoflBxxn0r9JtC+HGl6Do8dtaW8duqR+UmE/h+vrWb41+E/hXxx +4PudI1zSYL61mG12ZRvPGMhuxFAHtXgj9pbwN8bPg8jeFNds5tYe1HmxHLFHK4G8fwkn8K/O34ze +IvEGh/GG80HW5Qt7AqsHAIRgScYzx1r558cfDH4n/ssfE+08ffC+8vb7w+rGG5tjllkhbOYpFB7A +5DV2Wl/ETSP2lfhdL9tk0jS/ijo48i3+1XbRrLGTkELn5iP6UAfr5+zJ8OLvwr8MotVvdQuL251S +NLqRWOFjyoIAr61UYQD0FeQ/BN5z8APDEV3JHJdQ6bDFMydCyoAT+lexYB96AGUYNPwKWgCNkDQl +W5Br5T/ah8I6Zf8A7Out3clsslzbAXETAYYEH1/GvrCvD/j9bNdfs2+KkQAuLByM98YOKAPiL9iq +K3HxT8cW5jVnUQvGx6gZI4NfqTGirEFUYGK/KX9ja6Fv+0p4rtndf3umo+M9w4/xr9XEIMS4I6UA +OoozSblHVh+dAC1VuhnT3HXipvMT+8KqXdwiWL8joe9AGAoPPFOkX/RXzTgCDTn/ANQwoA+R/izF +/wAVKGPKlDgetfmh+0E+nLrFn54dL5lH2TYMtI5ONuO4r9Jfj/qFrotmdTuXRI44mLEngcGvz9+H +XhO6+Inx2/4Tnxf5ptYbh10TTwMpGRwHP6cUAdp+zF+zJP4z+IFv4g8a28xWGOKYL5eCADuC8/qK +/aDStMs9J0W206whjt7aGMJHGiYAArifhp4dj0XwHAxiQXUyhpXUY5x0+mK9LVcUAMKcVWlyGGOl +Xz05qpcDEZNAFB3A61k3Gx3J6n0x1q5cPgEd6xJJMTHNAFC/0rT7tClzbQSIPVBmvMLn4e6Rb67c +XdlZwq07hpiRncR06V6dNOPM64+tQF8HcckY7c0AcFb+FNNhgYC3i8wdWCbRUdzpdlGuFjHHcCuq +1CYpFlQ2Se9c1PORuXkkigDmL62jWJAqfxelYEtuU4UZ56V092ZPJL/dweprJkQtuc79w6YoA4vV +dJtNS0u6s721guEkRhtdQRyMd6/Ff9qf4KS/DL4uDxZoEVxaaPeEOPs+Va1kGfm46jJz+dfuVNGs +8GSxLA8jvXzp+0B4Eg8afA/VrN4/KuooWaGYLlhwTQBf/YR/aLl8QaBp3grxRqNtc3gsYza3sT5F +zhcEsD0bjkV+pIuo/LBDhhjrmv4+PAfjrVvhN+1FpHm3t1pWmremGa6tXKzW7HjfjoRX9J3wU+L9 +j8Qfhdavp2rx6rqdhGq3gA2uRtGHI/2uDn3oA+uDexA4J5+tMN/ED3x9a88S7upow2GTIyQDmmlr +lpMGQ+2TQB3ralEGPP615D8ZdTiPwM8RrlfnsnABPtXSfZrhxuJevOfifo0138LdVjILZtmwPXjN +AH5xfs++Jf8AhF/2v4HYOUvYWgfnjqCP5V+v9tr0b6bGyvu+TjFfiz8PbQXX7WXh2EKoR7g/pX7H +abpoGiwnoAlAGu+vjPy5P0qBtclz0asXUL3T9NtmeRkXb97ccYrjJPiT4Ztrry5NUsVc/wAJmWgD +0Y6pdO3y7h+FVLy8v205znsccVFouvaXqtukkE0UqseGVwRXVfY4pdPZlwwPSgBPtCD+IVFJeIIH +5HtXnD6+4J5HHvULavcSRcNgepoA8J+P+iSeMrvTPD6TbYJnMt4yx5PlqckZ9T0qD4VeA9Ni8TaW +tvFi1tmJIcY3MeRx9Bmuj8W6xHpxS5uZYxc3Eqwwr3bJ5/CvSvh5aK0cV2xiUMRsjVcHPTJoA+g7 +RFjsokUAKB0FW6rW/wDqUHtVmgBrMAue9U5zuXOasP8AKMkZ9qpyAtGTnA9KAMm7GMk9MVzUzYbk +nJPFdNcgDJOTx6Vz158iltvXrx0oAzWAOfm/IVI6qUXLP+Aqtna2BuJJx0rXS3DRZPpjA9aAOS1A +b3cIWYD1rl5jiZvvbSOSD0rvbmyzIQCVbGc/zrlru2xFKwQhc/dFAHPSAtjIOzoCeap3EZcGJsq2 +3IYKeaufP5qD5goPAxSF90rHf83TINAHNtbqB8uVnJwc1zfi23YeDbxRF5p8k7sLmu/8tTMemeu4 +jNYniC3WbRXH3d0TKcHAORxQB/O/+0X4TGnfHDV7kwAWtwxLxhcEdwR719n/APBN/wCM1hpHxE1H +wVr4cXs8I+zXTHLSRDpG/rjqPbFeVftRaOV8a3lvL+7uoyT5eM7k/vZrwf8AZ28Rx+A/2vdC1yec +WtlHGxedVDHGRxg98n8qAP6MvFHxg8MeHb2W2uNVtRMibvKjILYxkfoRXlQ/aT0l74Rw+Y6g5zuA +r5TtfgP8VPjF+2r4k1SDV4dF+G8kUElvejLyT70VnCqOnJIHtivraz/Yl8FWunfLrPiFrwIMyNKv +X1xigD0nwd8dvC+tahDaz3y2t052rHK+M/TtXr/iXUNMvPAN5+8WRXtmIIYYIINfnR8S/wBnbxf4 +BsH1jw/e3Wv6VCSWiCDz4sd8jtXI+Hfin4rm8ItoralLJFHF5WyWP94oHGDnnigDiPCur2ui/tfe +H724Kx20OqOoI7gsRX6x6z8QtE0P4cyatdXESQRR5GJBuNfj9eaHv8epeyNIiRyiQtjGD611PjLx +l4n8QX2keCdDmu7/AFC72xwxLJuGT/EfQD3oA6b4k/H3xZ418bvo3h23vnDyYgsbI73YerEVgwfB +74/arp66uPDDxqfuwz3KiQ/m1foN8Av2evD3w58K2t9c2seoeKriPdfahIoLFz1VM9AK+pU022SM +KI0X6DkUAfiRo/jz4k/DLxoIdRg1nQLuFvmtrk5hkHoCTg56cGv0n+BvxusfiT4ZuYZ1S01i1Rft +NvnoOPm/Wuw+Lfwp8PeOfAF/a32n273Rib7PPj54mAOCD9a/LPwh4lvvhH8eZrmTcFj821vR03gE +YPv0oA+wdT+JOmWCmeXVokY9FZhWt4O+KmieINdOnw6lZXE687N3NfCei/sifHfWvD0V14m8e26X +fa2SIkD8a4bWvhr8WPgd4ph8QXEyX9tZzh/tUbZ3A8EP/k0Aff3j3UDd/GvT9Osx5zgRFEYZX5jy +R9BX1Z4LiA0a0d1EcxGWGMdsV8QfDjVLnXviPc6zf4897G2KW5XhGZclgTX3/wCErMy6fFKuPLRQ +oHtjP86APQbc4jUe1WjytQDCqPYU+WaOGAvIdqjqaAGzEbTyOlUTINmM8is+41yyDOvmqFH8R6Hm +sefXrMT7FuIM9wW6f0oA1rmQFWAZRxXJ6nchoGUOcj0ps3iHTPtBj+22zT4zsVwTzWNdXkM29VYE +hucUAWbb57sFi5Xp1rrbMp5Wzg/hXHxPHGZGEinH88ZqzpmpCe9aLzASvJxQBq6tIsKk8Z71wt7d +K1wI/wC8Ogre1qVpZv3WXyOOeK4p3mMiO6qnXnPH60AMmh+csMHH+1VWe1PlZBNOa4T5klnVJeoD +EAH8azn1yzRGhN5ayFW+crKp2/rQBYVGR8E/LjFEkMc0Dps8xAhDJ7mnFormz3wzblOfmXnBHak8 +mS3tFcgNuOD3P40Afkb+11p0ln8QEfyE+0NG2GI5MeT8v86+FdOt7P8A4S/Ss23mWksiM5Xg7DuW +T/vng/hX7J/tYfDNvEXw+/tvTYQ8tsv71UHzNX463dmdE1WeHMqtFd9BztznP/6qAP6Mf2MbhtW/ +Ye8KXd5GXvbMS2byt/y2WORljf3+QLX2AqgghcY+lfAX/BPnxRbah+wRplq0redaatcwN5h5ILBx ++jCv0Et9phDD5uOwxQBmXmkQ31q0UsaFXUhsjOR6V+f/AMd/gMnhjVpfHvhmN47czF9SskXK8nJd +cD3zX6Odq5/xLpMGteDNT064UFLi2ePpnqp7UAfij491a2ttGa4twhDruyD1Fe+/sYfDo65far8T +NZt/MneU22m704SMdWGe5r5R+KOkXeia5rGhXqTI9lcPCMjhlDcEfhiv13/Z68NQeG/2b/C1jArA +LZISSO5GSaAPdoohDbqiAcdKmzmkZgqkk8Cub1bxLY6VCXupo4VHdjigC7rlxFBocrSHACE5r8TP +ifa/8JZ+0JqGnaQHmlv9UkjhCdTzzX3P8bvj5YWHhe/0zRLuOS6ljKtcI3CDHOD0zivGv2VfhjqP +iv4nT/EvxDaulhb7otLjljIMjnhpORyOeDQB+gMccSLsVVAHQcV5r8SPC2m+IPAGo2d5AJUmi+Yd +jg969IxtbJzXHeONUt9M8FXs88ipEsRLMegHqaAPjn4b6dLZ/F7UQd8sjgMGEoIjijyAAv04r9KP +DlukPhi3YIwZ1DYPB6V+cHgvxRpD+O7S+02CNreOUW81ymPl3PkCTngZ716n8VP+ChX7MPwN8S3P +hHxh4t1S+8UafDG13p2jaa9w8bOoIXcSq5wc9aAPuaV/LhLBCzcgDsa8u8R+IL5JTH87IeCqDBHN +fOPgv9qH4sfG/wCHY8S/BT9m/wAQR+GLpd2m674+1uDR7W9TODJHHH5spXjqVGe1a2sP+1rLpLTz +j9mnwyQMiSS+1O8KDv1gUUAcl8TPH/jDw8zvY+Gtf1lQ/wA6WKDESdiMn5j6ivknxP8AHT4k6pLP +YaZ4A8QafbSykW9xJK6SSN0OVwdvT8a3PiR8Xvjf4X1aTTda+L/wLudRmQywWml+E7uYEDqCxcYP +1r5ktv2j/jPca1DDc3XwpmBnKLnTrm3Zz6/KzYoA9Eh+IPxIu9YjkvfDWoadqUISIXKxMzEbuWJ4 +6fSvpnRfix4h0uG0tdVmlluDKUQgE+cOCD+Wa8Y8M/FD4rX2mC8b4c+EPGzRDDJoPiTZdfMM8xXM +anHXvXRaR+0B8Kz4ks9A+JPhTxd8INWL7EfxXpgjtAxz9y6jLR4PqSBxQB92eHfET6ppqzApIzxA +8HjJrQh1uPR9f3zMiIRhgetWPB3g6K68GWWp6K1td6dLGr29xayCVJlPRlZcgqRz1rn/AIheGb59 +MutsUlvc+WRHIVOM/hQB5F8Wf2l/C3geymhkik1O6jO5bZDjdnjqK/Orx9+2T8QNR1NhoerT6RZw +zOu6KNeehA+YHpz+dYnxzbR9C8feX418U2sd+rsfs8MvmSYHT5Rz36V5nZ3GhT6TDcaT8KvG2r2d +wQYrq9gjtYpj3I8xt2D64oA6Wx/bB+KGoavDa39zc6lbBSZHiiAd/TOAK7VfiF4x1uddRsbTxLbS +mPDfZ43ZexYkY54xXEWPxO1zwzrzR6Z8EfCNo1uVMi6nqgLIp6E7UP6E19D+B/2lviRf+Ko9Mt/B +nwb0uW5IEEOo63NbRMMfMRIIiOlAHT+BPF/xOQrNFd3UIkkU2ofiM9jvBPfHpX234E8bXXiOxey1 +zR7rRNVjj+ZiQ6SEfxKQeh9K8X0bWPjfq+lpqy/Ab4WaxpUsfE2g+OUcyYJyQssKjP41har8edR+ +HYWbxz8APjd4aslc4vNM0uDVYkA65+zyFto65K9KAPqnXtNg1TwvPaXG0FlwrEZGcelfz/8Axn0t +9G/aB8T6e0oSVbyRQDHtzzhSB9K/XXw9+2T+zZ4xttkHxU0bSL1JhG9nrkT2UyOMcMrjj0r84f2r +H0iT9rqDWdOvbG/0TV7Tzbee0ZZI5M9JEYcHoR7HigDa/Z0+PHin4YfD3S7fT72M2E2tTxypOpEb +nZGcA9mr9d/g3+1Jpvi3xrp3h3WLM6beXqYt3WTekjDGRwetfN/7CHwe8LePP2ANXufE+g2Wrwze +Mbs2b3MKkgJHGmVI6DKnv1zX2r4U/Zw+HnhTxZHq+kaDDb3cZzE+4nyz3Kg9KAPpKORZIwynIIzm +ormZILOWWQgIqk5J6cVSghktbVETJOcc9Kp+IrG41DwxdW0TbXeMqCvagD80P2jdD0298WanrkKW +y+bOAz7hg4719Ofs5fE7TPFngCHTUljXUdOjSK4iB6DHB+lfD/7QX9taJruoaBqAkXyJfMR8fK6c +4P1r339iv4e3dj4Nv/G9/Jum1kr5Ean/AFcanA/WgD7/ALzzG09vL5Jr4B+NHgL41eIfiIp0OCHU +tFf5IlFwYxCfVh3r9DQuItpwaryWkUsgZ1DEHJJHWgD4D8AfsmtLfWeqfEW9Or3Snc2nRDFshz39 +a+6tD0Kz0bSYLSygitreFdkUUabQorZSKONNqIq+mBVjjbz1oA+XvFfxF0nw7EzXN4iEKTtB5OK+ +BfjF8Y/G/wAUdI13wX8IfC+veJ9R2hb+ewt2dbVSQAXIGAD/ACr6O+MHwBuviFOHh1rUdJlAIL2s +hGc19E/s+fCjw/8ABX9nqx8O2Amnury4kuNR1CfBmuJGOAWYegGAO1AH5p+D/BPj/wAMyQ2nikRa +JDPpyz61ehCymBEIkG0DJcdABzmvCP2tf2O7Dx18B/Ev7Tnh+bxfoWvJDHe3vhvXIVEcunxhEyvA +aJ/LBkwxJGce9fu14x0XRPL0uQ6fay313qEUcbsD8gzuYj/vmrvxK8Gad46/Z38a+D9St47iy1rQ +rqyljK8kSwlev4/WgDA8B3+g6P8ABnwh4e0wC107TdCs7e0j9I0gQL9eMc9818c/tQ/GDVbLTV0n +wxBc3l3NP5dvaxNtkuJMckgZJQf0r6I+CenQeLf2BPg7qkgY6u3gzTrbUW6EXUFskNwrZ/iWWN1P +uprc0v4baZ4d1afU2tYLnU3yPtdzCsjop6qpPQfSgD8qvjP8IbL4cfsC3HxK8V+E9R8Y/EnxD5Vs +ki3726aCZlyJSF4cL0wcZJHNflh8Ix4k8U/H5tNsYtdvvJMkxiRAjEA4AmJBABweByQeor+pTxjo +un+I/BOpeHdat7PVNGu4TFNa3AwpHb6Edj2r5MtPgr8N/h3Ndy6FZXuixTNulSDVZZBIPT5jmgD5 +C1Pw/p3w2+KulaUuqXdrBeW6SQNE7+ZYyPjfGxA5jB7E19C+G7G713Vrzw547tNM1bwRJYsNSS7U +SW7W7Kd8nIOAF+YntRd+AND8R+N47tNEdLBAwmuJ3O+XJ7nOTXR/Gn4d+JdP/YWtbzwpd22n6jHf +2VlpOlSs63GtXEsywWlkrKwxvmdC+cr5YcngUAfGf7IH7JHjz4taz8X9Z0j4+/Ev4W/AbRvF19ov +hy38O6i6XGpmCU/OvmZVIkRkXhTuJI42nPo/x1/YZ+IHhb4banrPw/8A2qvjLqGpRRM4tfEV+Xiu +DtJ274ymzOMZINfqv+zt8J7H4Tfsd+Dfh5a3BvU0OzaK4vCpH2+9aRpLy6YEk5kneVgMnAIAJxmu +u8X6dZXPh+5tbmKOWCRSrKy/ez26GgD+YT9mzwx/anhjU9cv0XxH8So9ceC9+3sZ57dUO0Ehs/KT +u59q+1Y4NQ8R/Fa18Ixam8d+I9mpXgcFIU7xRA8AkZGe2OnFeJf8Kq8W/Cj/AILBvovhTWrHT7TV +rifUIxODtvbVsG4hAx80mzayDsd1fTllpPh7SfGP2m20bzNQhuGaaOclnQMxzznnrnNAHwF+07p4 +8O/FLxDp9l/bemT6TqAt7Kz8t3gS2aNT5rOGyzMT9PpX1Z+yP4A8PfHD4a6x4Z1HRr+WXT9Jjnm1 +LVblW+z3TMw/dKACkZXB2kk5zzX1BrXwM8EfFSW31G8SW11Z4ET7RDc+UxxgAOCGzwK9o+HnwCm8 +A+Fp9E8LS2WmWFzk3twDumuTggFnAHAAHGKAPjT4aa/47+Dvx1l8Im4v49Fiumt4lnB+y3MYPRc8 +KT/eFfpA3jjTpvBUOoGb7HOQP3DOQ68cjj/JryM/Ae61W626tqF1qQChYy5ztGc4Bz2z1r1Pwl8J +I9Itxb6jqEt3GhwiyYYgdsk0AfhV+3l8OIPHn7efhq58A+HILK+1vQpJdUaO08uOV4JGDXDgDrtI +BOOcCvcvhH8GvDPxQ/4JBaN4Og1nTrP49/DbxkbeWOdtsws7+72qjK5BeB1lVlYZwy4r9FW8B+Hr +z/gpBq2rf2dFLHoHw+t7BUl5Ec95dzyv14OY44/w+ted/HvQ/C/hn4t/C/xbqejaV/Yt3fLo+pq8 +KgbWYywPlRkMkq5U9iaAPrL9jLwU/wAPv+Cavw10i8Ahubq1l1K5DJ5e1riV5QCD0IVlFfU8TB4l +ZSjIehVsiviXWNNubvSDd+ONa1PXtB0uAtZ6X5ax20MajCqIYwokOAOZNx54wMCu4+B/j8ap4hGi +xWk+naTNAWs7WVt5jIG4Ef3QRn5e1AH1VwR2NI5+RvoaUfdFUdRuUtdJmmdtoVGP5DNAH5k/tXQr +qnxIv44UDLFbAOQM8n1r139izxXHqv7PEekOcXWk3L20qkYOM5XNcN9mj+IXxJ8bM4+0Ib1xGSM7 +VAwB+lcx8FHl+E37buq+Db92gsNfgWa0APymRf64oA/UCiobV1ms1ZWyCM5qzx7UAJt5p1GR60ZH +rQB5oFBYE5OPXnNdLIobQ7FAdqMyjA+tYIGOxroLMCa1tQ/PlzDP06igCj4jgjl8UeF45BuAvmeP +nphD/jXYj/V+uBXBa1cl/Heil8lYZ2x6DIxXcr93k9s8UAfLOnaT8Zfgv418VW3hrwpa/Fv4Vajq +k+p6NpthqEFlrOhSXEjz3UJ89liuIGmeSRNriRd+3aQAak1f9prwZpemLN408EfGTwLCx2tPq/gm +7ECt3HmorKfqODX1HkZPSqUzSeWyoxXjggkYoA+Gte/aL+BWoWgvI/iXaWMDtjbcWU8b4z3UpkV5 +PqXxz/ZyFw0svxH0zU5hu2KqTysfYKEr9D9R077W379VmbGCWUHP6Uy28OWULCUWkEZA/gjC/wAh +QB+f/h/46fDjVr54fCnhT4p/EK4i+ePTvDng27k8zjJUyOioPxPevpTwP4V8feOPHWj/ABC+K/hq +x8D6RoTyy+CPA63S3U1pLLGYvt+oOvyNciJpEjjQlYllkBJYgr9BxSNEwjMj+X0xuPNacWLm8RcH +YvJPqaAL9lapa6XFbxLtRVwAa8+8a7k06QxqTg9MV6TI6RozOcCvP/E9wJbCYLjBFAH53fHb4Y3X +i7WdJ8TeE7m30H4i6O63PhvW5Uylrdx8eXIO8UsZaNvQEHtivID8WvC8lzbWHxc8M658GPiKF23M +txpslxpV2y8GSC6iVl8ts5AbBGelffWoWMFxJcQTACOTBBB5B9RWVFa+TcfY50MJPPnFidwH6ZoA ++cfC/wAVvg7dygJ8SPB2+JwhY6ksbDA7bsV9A6f8Tvh9DYxsPil4OWA9PM1eEDHv81WJ/B2kaoJJ +L3SNC1IN90XenRSH8SVyafB8LfALMDL4D8Fyucbn/seHI/8AHaAHTftCfATQrQR6l8Zfhxbyj+/r +MZP5AmsHVP2sfguNKkg8LazqHxH1mVCtvpfhXSZr2e5bB+VcKFGf7zEAcEmvSLLwd4S01Qtj4U8M +WpXo0WkwKfzC5roBI0dm0EREUWMBIwFAH0FAHiPwo0fxRHpnijxr4901tD8VeMNXGoy6ObgXD6Ta +JCkNraO44LpGm5tvAZyMnGa8Z/bVvIbb9ky1iZGMp1eAxvHj5DuJLc9OlfYshYq2CDkc1+d37eGq +xxfBXTtOLMBLqiRsVHAUAnn60AfdOif8Tn4U2+p+YJ0utLjdeRtY+WDn8a4r4KpJP8Z9LCblEM0m +Qp4AAP515d+yt4qutb+DGlaZdXD3It7IQhT/AAqFwB+VfSnwQ0dLG+u9SlUAxvOIiepBkIH6UAfV +W84POB2rxv4yeJx4f+EuqT+ZtkaMxx4P8TcYr0s3a+QSzEfjXx9+0JrP9oTaPoolyjTGSVVPp0zQ +ByfwJsp5tV1ed1LISvmHH8Z61yf7UGj3mg634W+IOmq63uj3auTGMEr3BPpXuHwKt47XwnOzgB55 +y+K9I+Inhmy8VeBL/TbiFJ4ZIiNuAeaAOw+Gviu28VfCrRtXgkDR3Nsj4J6ZHI/OvQ/MFfn1+zn4 +rk8F6trnwy1u5ZJrC8drIynG6JjkAfSvtM65B5akODlc/e4oA7EzKD1FMNwgNcI/iK3BO6WPHfD9 +KzpvFtlGfnuY1696ANlQW2kdDWtprkNNGccgEfhWJZyCWwjcNkHpitmy+XVYm/2se3IoA5LxvdjT +tSgmLFVDeYTnHCjcf5V6Jp9yt1o8E6n/AFkSkV5x8XdP+0fCm/1CGMNewrtjBOOvek+FviWLXfBA +XcBLEem7PH/6xQB6pTTtbgioy3ynHWkDYGc80AQmKMyHIxj0rPupdsfyk4qzNMVjcnGa5y9vMAAD +B5oAhmuQsuWLYBya7PTgselRytw0nNeVNPLcXgjHzMeta/jKXUJvhrJb6bczWN3JaNHHPFwY2IwG +HoQeaAOm8QaqkFiMH5jxgHp6V5zrOqwiwhikLnzCdxHavnX9nLRv2lbfwr4z8M/G7V7bxLo1hOh8 +Ka5cbFvrhDu3JKVADAALg4B46nNdPrN1qi6w9lcKsUsTfL5zbRj19xQB18dul9NIYgvykAkZ/X0r +Onl2aqLCeGMSIMlycg185eGNK/aGt/2ztf8AE/i7xRFB8K7a3aHRdEsI4xbzqwAEr4yxb6n8K+gd +RlF9NBLHuVo4/vEfeP8AkUAdHbqrwrIg6HoOlasKDzBgsuRzzXOWk5eNNhZcD5gPWuhiYpGrj5z3 +BNAF/agTaS2fWqUx2nJOFPGR1zQb2KRiELZBw2V6VBIwklGG3KOgI70ANLBQS21UXhjux9TX5P8A +7cXiO0vfEXh3w6/mSs9z9okRTw2GAHP0Jr9RNcv/ALF4fubjjcqMB7nFfhd8e/FNv4r/AGx3Essl +5b2t9FCkZ5AU7QcY/GgD9Fv2U4WHh/xZPaxmLyVZbJTIPkXbx296+m7/AOJvh34domjXd5bRXgiW +SZTJlskZ5A9+awPgj4J0Twj8MrS30aORjqMQOZCGZQcEknHoa+R/jJ4P8S+KP2i/F2pR3E0NibwQ +xiMZIVVAGP1oA+2bX46aHeaC0ttcmX5SQea8fv8AUJPFfjVdRuI3eLqpXpjtXiOheDda0vw3bx3F +2zKflGVwSp9R619BeGNOMFgo+YYTbgd6AJ9M8cJ4PYwSLweVABB/nXuXw88bw+MtDklAKujlXUnu +a8C8QeCP7cu43MbqB1bPNegfC/w/J4W8RzRR/NbTDOD60AeD/tEaTfeCvjVpfjTTA1ss8YhmdO5F +VbP43a9eaPAIJnZlXDlVzX1P8e/CSeJvgnfCOISXMEZmiJ5ww/pXw78ItPi1fU47W6jVQsjRyAdd +woA6+b4oeK2DYeVgx6KORWRceOvFl4JCDcljxjJr6Tg+H+lrhvs6cDnK9ad/wg2moxbyUAPt0oA9 +y+G3iODxL8MdF1a1ZZLa7tEuImBzlWXNekxkpMjDqrAivz//AGK/F1wfhXqHgPWZWGs+E9Rl02dT +/cVyEI9sAV+gEeSgYtxQAeNbBdW+FWrRkneLRpUwcbioJx+lfKPwQ10WPxIvLFbiGO0m5WEtyAcn ++dfTuvambHwfdrI3yPC6IxGQuQea+DPBkk/h79oi0t7qS3kzuRWCcuoCuG+g3GgD9IlcNHu7VBI+ +AeaxdD1L7Tp5Z+jn5SfTtitWcjqDxQBk3cxAZielcndtJI4+fac4HvXQXAaW48teSetT6do6Taj9 +ouMCGL7qnuaAH6LobQwiecAu3PPpXVNaWz2QjmjSRAehqGW7VQVyqjGMVk3eqxxxgLJlu/vQBmeJ +ruPT/D83kLsiij3bQeuBXxd4l8d2mqeNZo9sCXUWQBKeCB24r3vx9rc03gu/SOOV3MT7lA5AFfCW +g28moa1qs9zAIriK93QkOcquOQSetAHsGk+Lnkmis53jks5hveSInauD9017NpTWE1sjRliNgbbt +xg88c18369p9jpi6bcyLkTtiMJIQu4jjOPerXh/xRqekSzTXRnnRo95CNvLkHG0enWgD6aitxHdN +Mny55welaMDliWOGUjGK8dt/iEspjR4HgZQBJuHKk9M12uk66l/cI0MgRGcocj7rYGDzQB10jhYy +Q7AdCoFQmQmLaoGCOAPX1oM6l5AQpOOF/rWNc6tb2FrJcSsmVUgJ6nFAHjXxz8XL4c+H+qwRTJDc +R2jujHkbsHrX4W6Pcz6h+0D9v1BozcSXSSJK0m1Qd4AJz25Nffn7S/xVs1tfEVib5BeMoEdu5wGA +5P5Zr82fB13Pe/F6zinRpruW4iW0VCCOWAXPbGaAP6aPh9FDY/C6xYTGeOG2AEqsCDhQDgjsSK42 +40OKe7mufKV5biVpHyOpJqD4YyX0XwVsLC/uYLi/EYS4+zsWSIqACBjg/wCNeg21t8hkYEKoxz7U +AeJeLLWOzkihVBkEZA7VveGZIWC26bCwAPJyayPEkjT+MPJTEmSQSQTiul8I6LLFqr3Mm1lzgcUA +d/Dbj7OuEUH1x1q0irHdo4UKc54qZuE2jjFNwSBu7UAd3IsWq+E5IpFBV4yGB7ivzbFtJ8Pf2vNR +0xgYbCe6MkBx1zX6K6BdKY/IbpjGDXy/+0x4JIgs/F1jC32qylzI6ngr3oA9osZvtWkW8y/xLkgf +Snyjgj73qK86+F3iFdc8A2ZLbpljwxByOK9MnA8rjg45oA+Fy8/we/4KzQOSYvDfji32njCrdRk8 +H3Oa/UXTLkXOlRvjhlzwa/PX9t3wrej4RWfjrRoHfWPDGpRajblRyFU5cce2a+t/gr41tPG/wX8P +eILOZJIL6xSYAHJBI5B9waAO88Ypu8C3eACwU4z06GvhdZ7OHxJC8kdvHcacSkcu7LsG4fd+lffH +iGAXPg+8hHDGM8+nBr84fEyz23jzUYtPhDPGyuzY+8GfBzQB9l+GfFcK6NGzXILsqeVG2OV2jkV6 +/balFcWO1yiOQAvzdTX5/wDh3xVFe6jLYsXihkt1jgBPzxOh5Of7uK+ltE8R+dYCNJDJNakLPnsw +H9aAPahDh5JASMDr6GrWo3/9nWixrt2hNzMe3HJrM06+GoaArk4fHIrjvHurvFo6x20ck88oUKB6 +5xigDattQm1GFpVJdNxwcEB/oaR3s4pB9puVY5+4vJX2+teQ6f4c+Md9qdoLOXw9b+GXB+1wSXTp +dKexTClcevOa9CufA3jRrURabrOg6MeMO8T3EmcDOTgdeaAK+r31mbCSEaZcSrKfndmCkj6V59Jb ++FNKL3EumD7RKxMpYIM+nTis/wAU/Df41zazHNB4u8G6nZRkma2ms5YHPPADKT/KvNfGnhn4z3cN +vp9l4X8LPCp3yzR60wIOOmCm40AbvifxFotxLHbLo7XtuMDEJH7r0OK81nvPD5tZbYSjT33kKr/u +y34j0/rXlV38LvjRfeK7q/uvFsPhywcKBYaYokIx1ZmfBq2fhNr8unSwan8QNTvA0ZCbbdGYMeOO +M0Ad+1tdRadcPDi8jbDYS4Jyv+93ru/BkE1rpdws3meaAssblicgHGK8e8G/BnX/AA14Unkl8e+I +NQnL/uYbjAjiXPQjFe/ac/m6fFAAGukdYiVXaCCRk0Ad54p1ZNC8H3GpMIUl8jKZPGcV8E+Lvi3d +Jpup6tJeCODHlRqkhI8w54x+Br6F/aN1y7svhbJa2ilpSmxUH8bHI/TGa/LzXry7bwtdWOozNBZW +YeZWjGfMcDnr6ZoA+fvHPiLVfGXiy+v9TmWVoGkCK6YEkZruv2cfA8HjL4sq1/Oum2VraNcI6AF3 +bdhVGa8t1xoF1O0061DS7o/MXcPu5Hc19ffsq6ZE3imGeeGNLgwsjxbcAgdCPwoA/XrwVYpYfD7T +LK3thBHFEFwrZz6k+pPWu+v/APRfDjSDG4qc5rnPCke7RLIKP3eOlWvG18tp4VfB7dB9KAPKrErd +eJrlmJyrZznPfpXrekWogsQVHykZryTwjBJI/nMpyzZJPWvdbWHbp8SgfMR+lAFZlO4EHGac+DFx +96pMAscimBeelAFywlMGqJzgN39K1vGehWfiX4b3unzIksc0JU7RXOKxznHI613mkSi60jY3U9c0 +AfBvweu5PDXj/VvCt3K8Ztrlkj3emTivrJk327OGBGBj3r5W+K2nS+C/2pbLXBGwstQfbIU4APAr +6U0O/j1DwxbXCKwXyxgE8mgDpfid4Zg8SfDfVtNnQSR3Nq8ThhkEMMV8V/sS+KJdA1Xxf8IdVd4r +3w3qMi20Uh+Y2zsSp96/RbUoBcWUkbKXyMH6V+X3jK1l+D//AAVU8GeM4kNtoviqJtM1F+ieaAfL +J9ycUAfqpcos2jzgdWjOPyr84viPfQ6D8VtWhNu11cXtwLdsEI0S/MwYDoRkV+iOk3X2zw/HMGD5 +HGPpX5i/tXRzaL8evD9yqv8A2ddBmu+dpkCg4QHqCT6UASaVdWcmvPLDbra2N4gSBwdxDA4c+xr3 +LQdWKeMriDzpHiuYVdiOnHAb68V8Tx+JNUvtVsNTsrYQW1w/krHPJt278geWPUGvqDRIJbSW0S1k +23SRmOWRyZBhTkqSeh68YoA+xPBmpi4t5rfuhZSexA71uPp6ahrMUMm8LG2c+o9K8k+Huos/iWFY +GMttIm4+gPfivfLWPbfh8+tAHR2sMNvbBEUKo6cVBO8Y5LHHSmtOFBBOfrWDqV0PIfnbQBzHinxT +p2kWDtdyTfdJ3BfSvnfXvi3oUa/ahcTrHK4QOy/catj4ntez6NPFDPHCJU+QnqOa+NvEOmz/ANlx +6fJfzqEuPNmcKQHHYZoA+hh4v0HU5PObzpYnyBKHxkg9h+NdLBLpk1ruto4Wdjxg5Ixivm/wt4fh +1CSFXkn8xQfLKyZGD65719AaJottp/lli0kkYG07uOev8qAOyhjQ2o/d8bTuB5oiiX+1o59mY/L5 +Zudp+lRK7iUgSgemBx+NWlDfaFLs+4rhNnAxQB8sftEazGusWcbySSQrEwKK2CWPRv6V+YfjySWG +Yx+aQiR5njL/AHC3O0+ua+9v2ntRT+1biAxOzW6eYHj6luyfng1+YviJdV1DUHnurhpzfDz7nDgi +NVzt/KgDkbaaDU/FEUlxujkW62xgNzIu3v7V99fsytZXPjVbR5Zlmh3Kh2fJJ7cfXFfnxClt/aUl +ufOEKSrslXh2YnkfSv0a/ZC04z/Gv7OZAbGCAyMBgruYDCj6UAfrpoFr9m020jCgGJACo6HgZrjP +iBOJoorZCWZ5MBRXo1viNlYHgj9MV5jqpS88bIOGQMePSgC54asDawwxSZJUDJr0xF2RAljkdvSu +a0eDcWwOAcDPtXUffkfKHOKAKLYy2PWndQMdutSeWAOBn2NI20oduAe+KAISACQOAeprd0O4MN0s +TO23HTNYgXMb5GafBKsF5G43HHWgDhf2hPCA134Q3V9bRhr2zAmhYL8wxycflXnPwV8TNqnguG2n +fM0Y2sS3PBxjFfWt/bRav4JmhZVKvGVOR2Ir8+vDjS+Af2kNY8PzqyRSzGW2LHqCelAH6SEFgcV8 +EftreErq+/ZzvfEOnRsdU0G6h1O2KDDAxyKWwRz0zX33GteY/E3w9Br3w01XT54/NhngeNwehzxQ +Bgfs/eOLXx3+zp4b161mE32uwRmcHkOFwwP418kftz6Y39k+GdYgVfOsNSDDPAIIzj6VS/Yl1u68 +H+OPiP8ABzUXKz6DqrT2Ubn/AJd5ckY9gRXoP7Z1otx8GrK52sdl6h+T64oA/NDwf4iiuvHOiWd/ +dCRv7UX552YeWGYnCc4HNforZGax1f8AtC1aE6XbttmgY7hIzcBwevU81+feg+EtQ8VfErw/aaHo +V9rlzBdrNc2ttFvZQrY3SEcR/U1+5Or/AAg0O+8DxQ6bbJYzTQx70HbjJ6dT2oA8U+Ft/bx6rZwi +Q+fkh0ycqSx9e3NfWNugDbuvH9a+WbH4ceKvC/xMfUGC3OjtKJC4OGjKjA+ua+mtMvI7iyjlJA7M +O+aANOaMueCRWNeWEsqsvJBrpUZWRivrVaZwEOaAPLdY8F2F/EZL6JnEa7cZ9a+b/iH8PNEh065N +v9raAYMgSZsDHP8A9avsye6jaFy2CFGCT2rwD4kavbwWrxR4lkZxny03GgD5d0fQpNOliubZJljL +gMj/AMJPTp2xXrdm8n2eMNPbICcEEHJPtXCazr0UUgtonEUk0m3KgqQ3b6mtjRmuDJFJczuwiOJN +2NzH2xQB6bbDYrb9wRVznGQa0lkDEfO3loQSwHQVjfaolsdyA4bAwWxj61C+oQxRsjMAmM/I2c+3 +1oA+Ff2ndatNK1y8mm8uNnm+Qq3zOW+X9OtfnLqek6tqF2iaetxcTXbm3SCIbyEH9T1r9M/jD8C9 +b+Lv7Ruj3iT21r4RjieTUYizedIwxsAA4r6p+FP7P3gvwZLBeR6RZCcbSH27juA9TQB+O+s/syeN +/Bvwo0bxprVheQW9/M0sVqkDSuAo4LYGQD6V7r+xhL/Z/wAZLuzuo3gl80MPtEbRkg/w/N3r6w/b +2+JfjD4d+FvhpB4U8RXHhu0u7qZL8wTKrOileNpHIr4F8EftvfFrTPGeq2Gm3ui+INOswsrw6vYx +zNKc4wGxuUe+RQB+5Wr3X9naA15kLtTOPbFeXeGp11HX7i68wMHJC5PSvMvDf7Q/hb4n/seat42u +3svBN3oUiw+I7S9uh5Npv4WZWPWNjx04OR2qT4X+KfC3iK2kXwr4q0bXZly5SyvVkYepCg5xQB9X +aVGiwFxgnNbBAy3OOK5bSzNb6fHmQSnp93pWtDdgHZN8o9fU0ASgHBNRhFEJZvu9zVtWUythuoqF +bm0kumtkuLSSZOWjDgt+QoAqsdm3ByT37UhVgDtKlvSrLR7pNzfIPSm8hj/Cv8NAHVaDdebZ+RJ0 +Awa+Nf2oNBfw/r+h+OLJTHIk3lzY/iBHQ19WaVcNBqAz909Ky/jJ4Uh8X/AnXNO2Bp2tW8okdHA4 +/WgD1xO9ZfiKXTbPwxdXWq39pplise6a4up0jjjHqzNivz4/bP8A26R+z9q9r8PPhxpVh4o+KN2m +6f7XL/o2lK33GlA5LEdE7V+Efxk/aa+LnxU8Xxad8QPFmr+OS9xldONx5GnxyMeEEKYVgP8AbzQB ++sPxP+NPws+GX7dmifEbwb4x0Pxg01tJZa5pXh66S6nlXIKE7SVHPrxXG/Ev9rHxf8fvGfh/4R+A +PB9no2oa3qMcFp9snF3dn5gXkZEysYQfNz6V+a2lL/whngWSbybG31K6XNy1rGIyncJkdcV9f/8A +BMeyh8W/8FPPEHiS9jDtoHg+4uLIHpFJLJHDn67WNAH6K/E+20j9nv8AZLuvD/hy6FrfR2IfWdeE +Q8+6cj523dgWzxX6UeFbpdQ+Ffhq/SQyLcaVbyBv7waNTn61+O3/AAUhu7iw/ZambzWhtLm8SC5I +HUHmv1i+D0zXX7J3w1ncfM/hmyPPb9yv/wBagDq763WSJwy7lPauFlhk02+M0Ct5ROWA616XMmSR +7Vz9xaB1IYAj36UAUbTVIZYMq6pxnHenXeoRrACDnIznOK4fWbC9sbuSa0L4IyQvSvNtZ8X3sFg6 +ywtvXjaxwGoA7TXPFFrb3HlzMQRGSFEn3iOcV4f441q3vLCK4sZYhIcb1Zs5JHSvO/EfiW6k1Z7s +28ylFyux84Pt+Ga87k8S3F5fBLuXZZQtuQLF3PYmgDtY4yZDd3M1u7AkeQ65Ue496ty6sselxiAM +v7z95KVwVXuBXDNr+nC1inDl3CEhc8nnjisr+2tR1TUUt7YM8W87jjqcDigD1r/hIrh4ooUAK9v7 +23sasRanPPNJHCTPgde34VwukaJqsl0skjh18vGxh69q9k0DQswmNrIKFQYB9fWgDpfDGlCGJp5s +jIBI7Zr1iyEvmKCsSQBflb3NcvZW7R2cMTDyyi9u/tXV2bEWwfGMHpQB+YX/AAU/8H3Wt/DD4W6x +BftbLb3N1azhT8hUhXBIr8gvh/oaWPjnVz9oaYG3QOSdoZs9a/eP9v8A0aPxD+wlqSrcfZrvTrtL +yJgOW4KsoP0Nfhh8JUc3OoXs9uDc2swt5A5yGwaAPur4C29pqg8d+CNSMR0fxN4M1Gx1FZB5kJxA +0sRbjgq6Aj61+Tnw68f+Lvhr8SdO8ReEtavdK1eykBBgnKI+1uUYdwcV+lFj4kg8EfCrxbqYlRLm +fTbiC3SEYyXUgkn6HH4V+UEEYS4ZZGIDMSxznJzQB/SP4A/4KE/DPVfgR4d1TxXpus2viGa12X6W +UO6MzqBu2+2c0/X/APgot8J4tah0vwz4Z8T+JNVliB+zIqIVOcbee/rX4h/BrS7nxrp+raDY6tY2 +eqWIF9apeviGTJ2spPbjmsvW/FOn+Cp9T0nwrfLqHiu53RaprafdhGSPKtvT0Ld6AP04/aQ/4KMa +xYeGo/Bfw605dD8RTRg6tqYdZVsgRzEuOsg6H0Ir44+HP7WHxH8KfEi18SjUrjU9QglEs26VsXce +fmDZPWviv7TKnmlndwc+ZHIc5J/iPqau6XevbzQE/vEWTK/U9aAP67vAvj218f8Awa8K+NdMGLHW +bBLtR/cLDJU16AsjeWn8dfIv7G82m3n/AATX+GT6Xem/ijs5EnJ/5YyCRtyfhX1lZfPbbd/3aAJ0 +fbcBuhU7jXokSpfeG5YmAcMnOfpXnz4EeW78HFdl4buM2rwvywPP07UAfyzfHrXr3xH+3d4v8Ta2 +7SXc3im7juNy8hElaKNefRVFfLuiaeH/AGsxBPHGoiunlRW4XoSDX1l8fLS21r45+NtZ0dQ+n3mv +3dxZSKeiNMzKSRXzBr1tdad4z0jxorK8BYQagy9YHPGT7GgD1T4jqf8AhDHuol3yfOrBex7mvuH/ +AIJBukn7QPxjuCokaDQrWFJG7K07HH0+Svh/XbpdY8IRfZwPKIw0q/MrDA55r6n/AOCX3i618Dft +++L/AAdqUyQDxXonlWRyADNC29V57ld2KAP0u/by+HV18QP2CfG1jpVnLc6xYW6ahZFOSWiYMRjv +xmv0D+DOsafr37JPw31fS2RrG48OWnlbTwAIlUj8wa8x120ttY8P3CXiK1u9u0c6OMkgggivIf2L +vEl54RtfF/7PXieaUan4WvnuPD0sp+W70yZi8ZUnrsyVIHSgD7ycbhis6eMZJrUPSs646Hgn6UAY +09usyuGAYY6E15H4v8KQX9nMPKBOMjBr2GRhlsgjisHVIhJZvgA/LQB8W614LaO9mjKOseNoIPIA +7+/WuCuPAdw2IbeZpYi2XA44/wAa+pvENnt1AOMAbeSa4janm/u0VcNyaAPG4fhpDHcCT7wABWRm +yy+oNdXBodjbTwrHbRKoH3k+UMR613JVSXLjCE4wPWgWyO4XylZO/qtAFG202FEaOVQUIGwBs4zX +Z6RAIoEXDu2OAwxVCGFjGoUg7Tg/LW/apsjVmZcZ5KtmgDai+VSAqJKRkMTwKd9ujtUd5SUXaOpI +rFu9S+yxSMyL5XZ8818b/tCftFWPgTw/cQWd2kmqyLtgj80ZGcjO3+VAHgH7dnxmcs/gC0d5EaNZ +LlY29WIVcevtXw/4Z0X+yvC6WYEUdzK/n3DgfxZztP4cVBql3qnifxbc+LfFDyXl9M7GCOT7yqef +MI/QCpbjXodP0J3uZPKIjyVbG4fWgDlPit4vjtvAE9ujqpkiaCMFcDOMn/Cvie3VmkWOEMXkPyv2 +X3NezfEPXE127t7O2aN7VGLu7OBgkV5za28EaNEkn7z+GQj7w/pQBt2WqXOheGr7SdFjSG4vlVdS +1DPzumeY0/uj1rn4U3bdxL5JABU/KetWktSS6EED72d2SfpS7UKOY3Ykp8qHhlIODmgCoqSbvMy7 +v/GMDFRu5S4RQNzHk7R0rdVYvMCLtQKP3jA5zWRfaVd395tsSXK9Qo60Afsr/wAEtfjZAw8T/AzX +Ls/a55W1bQGcj958oE0S/QLux7V+y0DtFcbQm2P1Nfy8/sk+FviP4d/b1+Eviyx0PUXsLbxFDFez +oQUjt5PkkDeg2sc59K/qLuUQyyrGxaMv8hHT86ANLKlBtxir+iXBg1rBcAP2FcebiaJtpJbFP0q8 +mk8VWW5WRGJGAOaAP//Z + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1 +Content-Transfer-Encoding: base64 +Content-Disposition: inline; + filename=avatar2.jpg +Content-Type: image/jpeg; + x-unix-mode=0700; + name="avatar2.jpg" +Content-Id: <4594E827-6E69-4329-8691-6BC35E3E73A0> + +/9j/4AAQSkZJRgABAgAAZABkAAD/7AARRHVja3kAAQAEAAAAZAAA/+4ADkFkb2JlAGTAAAAAAf/b +AIQAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQICAgICAgICAgIC +AwMDAwMDAwMDAwEBAQEBAQECAQECAgIBAgIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD +AwMDAwMDAwMDAwMDAwMDAwMD/8AAEQgAwwDDAwERAAIRAQMRAf/EAJ8AAQABBAMBAQEAAAAAAAAA +AAAKBAUJCwYHCAMBAgEBAAAAAAAAAAAAAAAAAAAAABAAAAUDAgMDBgcIDQkDDQAAAQIEBQYAAwcR +CCESCTEVCkFRIhMUFvBhcYGhJRfRMiMkNEQ1RZGxweFCUpIzZGV1JhhyslRVNkZWZhliooXSQ3SE +tJWltbYnN0c4EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCZBQKBQKBQKBQKBQKBQKBQ +KBQKBQKBQKBQKBQKBQKBQKBQKBQKBQWx5emeOta98f3VuZGVrS3lrk7O61M3NrejTkNdvqlq5Xcs +pkqezbKJjHOYpSgGojQYANyviVenfgaW3oTEHKb7gHhArXoXlxxc0JvdZsUodbY27cikSppSvZb6 +gOUl1AVTYEAE3rNNOYMEm4TxW26yTqpg2bdMRYvx3GHATJonJZOgdJXPmVPy8ntt21edrcRurrg+ +kBbqG/btiOmhu2g8CSnxE3VNmDYytxM6oowtaAL65dFoZF2pY8GAnKJnQxGw1u+U33wgBShrQdPM +vW+6qLGtfVybeFkxZdflHtN609Xmt8RN1wAAoEY0DsgWImVPoH80mt27Yjx5deNB3rFvEh9WCOub +WsX5yi8sQN9m2nUMsgxLjT2N0t2xKInXLGqMtjv7ScpdBuW1JDDqIjxoM1u1HxW7M+SWMRbd7g5F +D2NcYqF9yhixwXuqdpumJpad1sJcvWrrqAL2nryJFNy6Qgie3bOIBbEJRe27ebtc3dxoZXtyzXCc +otdq4FlZYZXC4kfG2+Jeb1DtGXiw3SJru6dgX0tvXThrQenKBQKBQKBQKBQKBQKBQKBQKBQYSeph +1ytsXT0FTA0xbmac93UN28mxxE3JIRujl09u57JencgAb5Ga3cuFD8XtW7yoxe0pO2gg0b9+rZvP +6i6qylyO5+7GLmFVfVt2OscJHZsiSQ94BtlUPd4VKpS9LCWB5fWKrpihx5Sl10oMSIqR1HUR1146 +9uvl19HtoK781Q/2n+5QUH5z8PPQV3/tnw/71BXJvxT6fi8/Z2UHJ4tFJZkSTs8Lg0ed5XK5AttN +zKwMSG+4ujktvmALSZGkTW7l67cMPmCg9o5B2cdQzY9djuRJlhzPWCbqv1LixS1K1SNi5DEL623c +7ybAKWwchR15bhg7eIUEifpY+JVkTE+x/BHUDXGd4/fBIzR/PlhKPfTSpADWbBMhI7YFBajOYCBc +Xk1ukH0jEENaCbFFpVG5vHmiWRF7bZHG35Cncmd6aFVpa3uCFVaLesKE6i0YxTFPbOHAdDFHgIAI +CFBf6BQKBQKBQKBQKBQKBQKCMv11OtNY2gs7ptZ24OFh23JylmEsjfkVwiqzixmcieqLqWyJ+aVr +rVz8DZH0rIDzCADpoGPboreHMkW9Qj5u96liTIzRAZUrtOcAx+rdlDJN8sqV4FWLZpLHS7656aIo +BTltprRQsqlxhMYp7dq2UboTacK9NvY9t+xPewpjHbfjVqx8sSqEjo2OjEnka55tqiGIoO7vT8Dg +7OF64U2nMe8IgHZppQQQfEJ+H6S7L7DtvK2nWHl329v8l5cjY6BECxVhlwergijcm5SjtgZRAlq8 +RslNdIUyC7ct2zmOU5TAESX8z+HnoKJL2j8v7g0H5w/JPj+P4a0BUq7ePw+nz0E9jwj/AE2Udxsm +fUVyrFrV4664rx7t6tvCIpwt2kl4PfOeN1tQQQAwqLZG5IoL2CRTyj5aCdEub0DolvIXNCjcUSm1 +csKEa5NZVpb9m6USXbN5OoJctXbVwg6GKYBAQ4DQRr+rr4dDbZvVgshyTtfhUK2/bqmlOpdWVfGW +61GoBkdXbIa8ZjmDA02SNTcvcTl5bTmmTkulum1vhcLxKEYDpCdRrOnTO3XOWwPeXdeI5i1NNluP +npvl9+6f7IJYF8bCBzQq7pzFtRZUpOT1glEbHs90t4no+kAT+kaxI4pEy9AqTrUK2xaVI1iS9bUJ +lSa+QtyyoT37RjW71m7bMBimKIgIDqFBU0CgUCgUCgUCgUCgUGKzq79Q9i6eO1aQTpCpRKswTYiu +KYejd2+T2pZIr6fS++GSAB791BHbN4t+5oXQTCUuvGgjLeHG6cl/qPbu8m75N2CZVkPHWGpOkfzp +5OQ65Bk/OUgv3XZsSuYKRPacGKHI7IrlScdSGunR2TlNaOctBsj7Fiyms2k6azaTp7FolmxYsWyW +rNmzaKBLVq1atgUlu1bIUAKUAAAANAoPrQWeQR5hljI6xmUMzXIo6+oFLW9MT2gTObQ7Nqy0awrQ +OLestXkqxIpsnEp7dwpimAdBCghudSfwl2NcuSCUZd2Fzdpw9InY6h6XYKmdpXexyudLXOptJYZI +khVLjEbKm9qS2lUWVKW2JgALlq2GgBHxhPhiOpE/7as/Zwk0eYsbTnFze9OuOtvjqrTOmSM0I4+7 +LrMqWoAQqLzbErZGtEYGg1s4g/Dy+QxRMGFHG+2LPGYXlHHscYPyVM3pfbC8ibmaGP70pv2DGKUL +5CIUN7WwJhD0/vfjoJaPTU8JZknIl6N5R6g0hT46xw4NKd3SYThjlfPlRdfUjbuWEcwcrjeLNEk/ +sxhNct2rqtYU/oHt2h1Ggnu4exFjvAmL4LhrE0ZQQ7HOOI22xSIxxtJyJm1na7BbFggnHW4pVXhA +bl+9cE12/eOa4cROYREOyaBQa+XxgGxhDCMr4i34wtuOmTZbtWsW5XOmKIWPfOKNhbkQfLnIGltS +7RhMZKc3DmFvKP3wjqF48Md1K5PMweNjebphfdL7SzLJHgd1kLlcV3lSBtU2SOsGa3NUIivsJm+9 +7Witc4mLas3ilDQtBMqoFAoFAoFAoFAoFB8VCiwksXlSq/ZTJk9s96+oUXCWbFizbKJ7l29duGLb +t27ZQETGMIAABxoNX/1p98jjvl3mzt7aXE9/E2LVazHmLURD2DprjWzq7tpzkIDZExDqH9eUTibm +H8Fbth5KDYR+HYwy14e6SO1m4lb7KN6ykySDLUpv27RCXXF1l0lde71ag5fSunLGkKG2UREdCkAA +4aUGbugCADwENQ8w8aBQfO7cC1auXRLcOFsh7gktWzXbpgIUTCW3bIBj3DmANAKACIjwCgpCAS/e +IIDp6kx7ptPKY2gAA/JQfidpa0hiHStremNaKYtsydGnsmtlOYTnKQbdsokKc5hEQDtEdaC4UCgU +Cgj5+J3xEqyp0kM0OSBDaXK8US7HGTDgYgGvWG5FI7MZdFCc2giQydJJxunEP/NkNrQau7EGQ5Ji +nJkVn8QdlrFJog+tr+yu7ddMnWIVzcqtqLF+xdIICUwDb0EOwQHQeA0G2w2gbi4dus254tzhCXhK +8t8vjLed1OmuWznb5OjTWk0jaVZLf8wqQuhLgCQQAeUSm00EKD0rQKBQKBQKBQKBQYlut1ubXbWO +nTnCZMTiLZLJkhR4tiiq2cbaiw6zk9xsNfTXwOT2W/aRet5Lo6gS4YvDUQoNW46PrQivpk61ztGW +rroXVgCcTnIa+fUx1BilMW2c4mEePy0G622Iw+O4/wBku0OFRK0isxuM7aMHtLOVuuWryE6NNjaN +lKoTX7BSWb9tUYRu+sKABcE/MAcaD1dQKBQBDUBAfLw830hxoLQf1SC6UeNmxasHualtmPZspLHK +a9bEpREbYgI6lEA8/wAlBdSmKcpTFEDFMAGKIdggYAEB+cBoP4vXSWLZrlwwFKXzjpqI9gB8YjQW +2y4GG4BLhP54/wCD5jgQxfSIF21y6CTnT+tKUQ5uY5y3ADiUAMF3oFB4Z6m8KbMh9O/exEni0N9v +cdsmY1N4gEC4cDssId3xNdtkEBAbthU2kOT/ALRQoNL4nD2NYIeYdO3X0QEQ7aDYIeFmyndlW0TM ++MxMa8kxpldsdm9SJtS+zz6NlOdKUv8AB9Qoi57g+f11BKBoFAoFAoFAoFAoIpfi152WMbI8KR0i +w1hVKc33r9pKUwgC20xxFyC6BygIc9uxdd7RhDjoIgPkoNcua4e5cG4cTHOY/MYREREwiP7NBvBe +l0uXOXTd2IrHJEsbV9zaXgQipA4Wrllakup8ax1ONhTZugW5bu2wtAAgIBpQe76AIgACIjoAcREe +AAAdoiNB8Qv2xONvX0y66lABHQOYQKOoAIemAagHaNB9dQ05tQ5dNddQ008+vZpQWNUrTXb3qzcp +xMnulKU9/wBnT3Ut/kC8e5d04h+D4AHk184UHztPyG1rbOJilAR5TAUTfMIAGutBa3dxBbb5bHMU +SCPIUeHMP8YQ837lB/aVVoOqvUBDyhpw8w0HJUd8bpClObU/IFwoiUSH9UcTerLeIIiJVFsoAFzy +c3Zp2AFbQeVN9hil2R7wzHHlKG13PomNygflKGKpXqPIPA+geTy0GlBWffD8o/51BNM8JS9ONtZv +Djw3rFtoVo8av1lvIP4a2tTukyRmv3g17RtLhL2UE0WgUCgUCgUCgUCgh/8AjCGlMp2p7Wng6stt +W2ZolSROiHTnVWnOKoTqb5fLokFvIA+T8JQQcNnWIVGfd2G2/CiYnObKObcaQq9+CG+W2ifpc1IX +G+eyACNy2nQXbhzBpxKUaDelxliQReOMEaa09lI2x9mbGVAmT2rdiwnRtiOyjT2bNm0Utu1bt2rI +ABSgAAHZQVaxWFg4FtiQ14SGONsboENyWiX7xdAMUwaXj2BII9oBqIdlBVCYR7f2KC3UFOqt3QKJ +AuH5f4uo6fHwAaC2+y+16ir7POH08KCt7rS+cP2A+7QPZE3mH6fuUH77P/Qfh/JoKyg/U14SXA5j +Dym4DqIjp5vpoOJ5ax/H8uYryTiiUnvkjWUIHLsdSAyQRBWVmmzEvi7mKbTiCgErocCeY+lBpZd2 +m3SZbUdy2cds02Xd9SHBeUJjClr4Hpd7g0iItEv11/3gYKCVR4R9vtlf95DlYDntCz4vT+u/j21S +6Sr0oecdCGPQTXaBQKBQKBQKBQKCLb4rKIR6QbNMHurtaLfcmrOV9sarImEDntP8JfAXmt6CHpWr +jYnNrx0oInPRCxoz3erHsAI1K7qhzJuIgkgMRSNs9oGZg1dH0pScgBqJPRAR1EAoNxGN843Qt8AD +yiAcR+5QR1Ooz4jzZhsNnMrwcwMUv3K5+h7TpJofj9zZWSC49lP8GLZJys73rgRyQX7YahbbrT3d +IA6HKU4CABHpk3jHd6QKwVx3aztNjbWIAX2GSzLKTtr8Yuhfcoga/EAUFc2+Mf3frA0DaXtNeOPa +15IyeI/94pgoMqnTj8UhjfdHlCC4J3UYSLt0nWTJaywqE5CiEyPK8SvElebxGyMM0q942Zhfcf3X +125gtc4GDm5dRAOYaCWpQUiVL8PN+/QQ3OoH1jOo9uN3DZS2X9IzA2ZFkh27zyXQTN+VIdjxld3P +vVnezNjQDPKZ3rAYBH9CmMIupROIecAAKDBpmRq8QgjUShFuN3vTrESOLNQOMp+0bffhSJtLQDqB +XMAeAxfNRLHwAA/RDpp+1QYiWzN+6FVOu8nffbJ2SQoBB0993Leq8urZ3v8A1QeBzKZn0+IWvT4q +DKTsK8R1vk2kZPi7TmfODvu/2/23Vojk0hE1d7kqyCMYKYmrphSXL7sfkJn7Ti1kdtQMJQAQ0HUA +m+QPxCfSAnDWmWWN5kVjKhU294Wm/IePcowJ3brQhprdK9QaxbtmAB4gBjfL5KCAP4gPJ22rNfU5 +y7mnajlOMZpxrleB4hkT7MIZcF5izbPysbxE3NquDykL6wxWJrMbQA9Iwhx0AaDNN4S1M8e3b0FC +VOnCKWLWJk15UY/40L5dPMBR2LVvTUycECS+Y4j96blAO0aCZ5QKBQKBQKBQKBQYM/EO4xJPem5P +pARImvrcZS2GS21fvCBbyZKpd7UeUilEe25cUO6fmAOIlKPmoINnTK3UxjZJvuwFuum2OpTkyE4Z +c5iL3F4OBe9yu+QoS9RaIGZuYpyiIP8A5wEKCQ7uS8X5uElTXKGDbltAjWGUrzGnlCyTbNUvenOU +NBjFO1HdmdmjAM7H3+xHuCYCmOblEA0HQKDExs26DnUV3vxlFml3RxnB2Pp45XZmhylufeXwMhT8 +XYAuuuQmqJtZTzx8HW+U/enATAIiHlEAzhYu8LvDMZKyyM3UIycabg26vr1C9t+MAaGrQdRKP2mm +KYAHTyCA0F5y34bN/kCZY7RLf02SjITO26RVFnzaviyVxN0DlE3MaXQUzyLAAiGnDUdRDyaiAYM8 +oYFyj0otyeL5Vvw6dm3XPuHXuSs7jB5diPvnHsAljtFHwrmBsdZExmDQVhyOQR1BolRTdnENNaDK +Blfxg+7G/OnxZhHadt4iGNwM19yseXnibSzIAl9EXQXV3gL2xx0TDqIaAQvKABxHUdA63HxW/Unl +qGQSpJi3Z5F4/ieLhkWVxVwieVQNkZnGaw6BGibPec34t23dD31B0AS+lp5Q5QoOhJh1A+rX1386 +W9rGHHuNbfIRJYy7yLJGOsKvr3j7EzTEBFgJKcsbh8i3CGkE+j4mAQ7qETcwiHDt5gza4A6B3Tn2 +0MXvbuDWOG7KQRdqNJJRlLca7PjRiaHtEVZjGdgHHZjCxmjhdeAu4mNoABrwoPW2y/ed0ud0ORXv +CWzxZhsJXAGszj7jt2BwxQ1S2Ksxu6TyvEoOeoz2PFHiJR0EAEB7BARD31kba/t9yvGFsUyvh3Dm +QIo8/Vy1km2N2R2Hhpr8dBCt6znSwwl0+ZPineNgfHLXNNs8kyc1QbKe1rI7w+OsAiUodWIHNnJE +5e1iL8aNT0veoB/qF8HTsAAAI++e5/D8jZJnT1ivCLbtZxVkV2iLrjzCceenx4izXFoqyvbaY55i +7CMhyCY0gHi7jxHWgl5eEmWorsa3jWb7mnB7vLsNqSs6e4AEO3Eb5oN1yJa11OFpUrJaEwcC84AP +bQTIKBQKBQKBQKBQKCPZ4jPcPCsebPl2AHO69qJtmpU33GFmYjmtOQoYxIGle4qVRSiIHTequksc +nZ68dfIFBBh2vwfI0/3Asm3LHolheeMzzvEeN8WSuTOs0x/KsRZta8nsznE3geYBf8fyMH7s8oDx +DQaDPb0pYvuT6j3VaSX+pI+SjLci6ceL3ZtW4yy0I92s+WMTTU8DaIhLCRnWNv0jYJ4dzdXh2MU/ +fpifWg6agITplStUrVe1q13tqseK5d8PIOlBgx8QPA92k32Crmnaf7+LUiKeM7ln6LYl78+0KW4o +Bk/VHuu9C/yCOd//AKXaKDzx4dSD7x4ZtVyGl3HtmS4phN5nTQ2bWoTlYH4ssi7Ya4c+RXpqZnYw +zqOY7K/HAjQDi5kIBSunDXURDNNvK2kId4exbcxiFZF41JLE2xbMEETROds4OLXm6KAcuO8gM98x +PUsV2APxRN3mBuflDXTloI6fhK4Dt9zHC90kTybtWw/KMxYancPkqLN8ziLLPpd3TkFmMIY7A85Z +3oWD3AFhAPqsSB6Qh5tQkwdUeMQzGfTr3gzKL7b8Y5YdYdg+YSRBjBbDYKaLu7mRsOhNKXW3dh9w +twIFZdjyU33hh5DCAlHQShHa8LDi9hadjOfshNK7+8OXNxgRt9XdndETx8ycGj/43QSBdxuCEm4P +A+Y9uKxc5oUm4HF0wxMK9t7Gd2lgatP66ZuHf7L81BHe6ZHh+M17EN48WzfuXzHA3oMSRl7DFcVx +uZ5M7OztKmUYAMseBcw/2eIwvLqAtPn8gaUEqBL3okV+1pVzWALWvu1chcmgHZp7p83yUGHjr5R9 +ieOlFveUqW9sSJkbZjaaMKAoCLU1O8Vm7KDQDOUA9IwmenTh5aCHV1KWmxHdg/QbhC1LzP6TZ5uO +kysugfWTRkLcFbcojb119LU5TG4/xvMFBx7p17jcydOp4xbvMxzOMQzmBZOl0txHljDzbKl16bRd +AjXWHYbmTIkoaSPbNdlFhLaVMbmhJdsGOTQ5DFEQoNlHivKLbleIMM2jxrfccla2x0QqSmE4GbHp +suX+cdQDQwAfT5qDtSgUCgUCgUCgUETPxDcXfse7jNj+5ppXNa3uWe43bEKGSfWzS0SyJ5RhcoaO +9v8AlygjZ573hbp5tv1w7lzdchbmXeDti3EQ6P5Sm7bEGSAyt5+z3JzG5RVryC0NJWMDSKBmJ3WG +gCPcGgajprQSfMJRMuyfxRW7HEKoXEsP6guK5dluDLiCUbLs65B0ycJ7RiiJDWTZbLKWwpg4CXQa +CT5QUaVVp5/xIfN+/wAaAr/G/wAr/HVfk4UFvkGdf8PuG8pziQssZVQnHkXyTlqUrnF4eOEUijG9 +Sd2NoZjHUQBlHTycezsoMLPhNNvnuVsGnO5h2bzJJDu/zJLZShAAKBWzH+OxNBIeQphEdBLctuY6 +/J5xoJQMij7FL2B7ikhRNrvHpK3Okek7IvKHdjq1OjQLW7tPEDehctjoYO3lEeIDxoIa3RwiA7AN +5HUU6W+QFzgilLPlQu4/AhrjV6tpyHiY7G9ldOQvKbnOeDPZXICiJf0F26gBRCSeKoEqpC7Jfypl +dGdyQh5wag00HXgOtBV5NnJslCyilhp2l1ZnQoA8md7YgRpddSnEvJbIb0x4jrrx7NOyg4y1pXT8 +7XfDs17aDAx4g7Ib/LdueEenzicBes8dQDO0Qx4yxZsIU7qGPYm+A4yt3MU4lAxDSQxSj26APYPZ +QYZPFRY5YsK7gOnxgloAyOI4J6fzXG0S5uDTkaGWde6PfHZwKJmQP2aBlLpCRjaJ4f1futywxlR7 +t8zZOwRkhWdxKbvfE+KHV91iWJWsOTlKHu+Yrq66mExtRDQALxCWZ0q/alfT72rq1n5WtxhD/wD5 +Hp5aDIZQKBQKBQKBQKCL14ophVq9r+K5Ck/U07efYfi/uS9a0EZPrfZFxTlffxK8m4VXtL28TTBW +3SS5hfGsNGj/ABAGxkzDKXaI+XmJa0B48mtBN73lbC5b1I9suwrfFgmWtUY3z7ZIdCMhY/kYWrdt +jmjw3JmxzmmJ5RbIIFtxpLPWZRbACh9+U/EObloPXm1LdXjvdjDn52iqxpZMrwt1LG8+YQcTC15D +xLlYSlGXNRmgSF79jwHEQaHYBHgADrx0APTir8U8vk83k+bWg/fzVc6+w/VKP8vfHL6paWj/AMX+ +UaCNv1I92D71DsksXRz6c8rLkCZZalLa2b3dw8F0v4v287fGF4tnmMQGSWjA1Pr8b1gFdTaemIGa +LfMZzMABLBwfhuD7ccJYtwTjdEDPAMSwaKY4iiINeDNFGfuu2A83pCY5SgJhH+FrpQdsJvJ8P41B +gz6wnTbypukJivdxsukjfj/f/tMdTyHELm6EZgi2Vo0S6VydcR5FtvJu47idQBvQM4hyl5R1HlNq +UPCeFOuBt5azDjPf/GZ506dzzO5dwZAx9mqGzUMUXJMICYrziXIIGfikjwELqIOhjAHaBjF0MIZF +Eu/rZGri77LEu9LaWtjzK1+8b4ubcwMjt3Q0/tUHhHcZ1uNpcIi/dO3CVf4tdx8zax+x3B+JWd6l +nvdLHbQIi0O/uv31Qc66WHS63GuO4OTdVPqj3W163sTNqux/C+IG7u0IBtYx4RoBsZSILbWpOity +K3HjgQtv9TEMfm5nQR5Qjz+LmB0V7+MXNuhraJm2kwFGhMJSgCh/c5llq6a3buCHMPs9sSmOUo6f +hCCID6IgHpjrR9SC5vF6cnSowLAUDiimu8xtxvnrIMXazEud2M+PALBWuLFOXQOSRZfHlAB/ia8d +QoJSu0zF/wBiO2nCGJ/y33LgTPG/bv7JZBoPR9AoFAoFAoFAoMOvXMw39rGwXIyRIhBarhbozzVA +P9k/pfj8TBQa62eJVUTS+1yFja1qTJzV/cicOXfbT3O7RN7/AL3C0eaR6/pag2anh3Mypsx9LXEF +62495OGN5ZkrH0qvCcTnM7BML2QiWzBza2/ZYvPENkA/iWwHy0HPN43R7xtuDzIi3XbfcqSvZjvX +ZGwW8mfMTs4OrZLWs+pjNWWcdg+MdvIFsSgUoauZD6E1ETcKDyFHtiXXwg6ebMEX6i+05+b5MYDM +02mOCZrbl0UtgICcrLFzEfrdoxihwEXMwAPHQQDSg4M2+H73R7glKr/qNdXTc1uBijwcRf8AF2F2 +pmwjFXkumoC7uTWF23eAohwL3SXXyjQZttm2w/arsDxiGLNrmH43jOK8omkrwQDuUolbm29jrMZW +5mF7kdz0dNTjoAhwAvHUPWXtSpWq/onZ5h+AUFd+SUFD7Uk9q9l0/wDUfJ+z2UHBMo4bxLm2PjE8 +xYrgmWY7x+pMiw5klzUHER5iklBDgIjrx4caDwE49EjpMOrkDou2CbdRXAGpu7Yd3UA8R491tTtb +IbT4gD5KD1pgfZ/tX2rpXNLt027YcweR8H2F5W4pxvH4k6ufLobldXdqt235+AptBATGMOvHXhQe +kknsn5pr8+v33x6+Wg14HiKMUZW3j9YiFbb9vURXZAzI9xHHUNjsUI+WmollRaht2bLpA9PGhAj0 +dZmY6ozsJjcvIQBAQ5h1DovpMbQVmY+pt7v+/Jct4n6frX9nEWnBQegiTs7Y+EYv3vEQcQAAjcgf +w710oJ/qVKlSJUKRJr+JfRQf1QKBQKBQKBQKDic8i7XNobKom7IfbWmTtbxG1yH+1vmANaCGht+w +Oyv8c6pHRrzBFYo4T52juYNyWyZ2dmcXV0bc/wAUgx3N3LjsSctz3kf2TldR5TlHl10HXSg9U+Df +3NN7rBd3W0tZfIkfrMoie6WItvrhMdQzZBisfx3kO1bsj/NhFFsRiwnEOBu+Sa6CHEJwPs4fF9H/ +AJNBQ+zAl0EBAddfPpQflB1JnCKTycYkyhEcUTdBjPJcjgkvjeOcid0keCwGfO7DcTxKXXmo2oHL +H3w/rBLrzCIB2jwoMT3To2z9YzblPTx3eDvOwTuxwCtI5OIuLnH5wXNrQ6uQga2ETl4gQl6NW9Ox +zMJigI8ogI60GUjcUz5vkGEpy1bcJ1BcfZsXNJhx3NskRI8uikTdzCTleHWKtgiZ8KQAN6Iej6VB +h56c2xfqt7eNwE2yrvi6kJd0uMZLFXhrQ4SaQmYM5JU5PRQtSlqLKGhlDH5I6Ajyg0l4ajxDhQZ4 +Ff5KOn5X5PP2cPh5qDivtSpIPzfP+6HkoKH2lV/pv+dQcqa1X417Jp9zt+Sg12uaN+cIxR1yOpzv +QTPI2JRt4wtm3Fe3VtsN9u9ZmO5M7dCttcJLaMUD2jJ2YJY4vbsbTmuWkYDqACUaDM54c/bSrw5s +tXZMkKD+9mdZR7ye3f1T+qOFBIWoFAoFAoFAoFAoFBg86r3Txyfm5+xzuw2nvjnC90uF3RncmNdG +/ql1d+6v0R3R3pr/AHjoIJER3X7sOkf1DU+VouwXMZZvhzurWziAPaNtSwuYQ7IjgeVSeASdgZrV +n2mPTNoUtdwTW1FtQiUprSlOa2otWrpA2nPTb6lO3/qc7a2XPOD3MELml9mZMq4rdVqe/MsRTsUv +r1cZkVqyFoVbcrAh77S627RErqjD1hAt3SKE9gMgntQ+Yfo+5QWSg/j2oPMH0/coHeof6CP0UFEq +fvZPzHTt+L46Cu70/Ffa/L5+Oumnm81BQqn5KrS+bT4aUHFFXYHyfuhQf1/ROPtn7vw+agwfdb3r +UYw6V2KbUWgp2LIW9DIzABsZYzu3QWIYPZCwRMiyjkpKmuFvJo+hV3Td3N5jW770rtchNLBL922E +SDoi9MJJ1EJBLs95hlrveh8QyevvPOtklh2yu7Op1zyZx7xt3rpbR1p043Dl5h0OYeI0GwTjEXYY +QwsUTiSHuWPMrV3axoW39UNLT+3QX6gUCgUCgUCgUCgUCgggeLB2cEQS3H27KNtyowuyW7FJOqTN +g2U4g3ktODStv3S8SlOkV3LIa66GTaeegjZ7BOoTuk6duXEud9rs7CJShU1Gjstj7y323+CZBjJr +9tRdjs4i6k9pO7toqbRbtm7bOnXIrwesSqLFz06DYRdJvxQGIN/uScdbX874cfcH7msgX77PFXKF +HUTPC08fG9pXvCuykUKDFmOPVitG3XTWEi+26oyFtiB3TnEhBCUd7UHmD6fuUFf7N8Xw/lUD2f8A +oPw/k0D2b4vh/KoOPt8qhrs5rmhqlMZeXVIURXMra8szk6l0Lz6GaymA9vgHboFB9FTCl0D5P3v2 +qDrOYzGJ48iclnk8kjHDoTDWJ1k8tlkmc0bLHo1HGNFecXh9e3dwvWELY1NiBPcvX7945Ldq2QTG +EACgiM7/ALxbO3fF9p3g2wyBr9w88spVaFNmGdJHaD4WYHG4nEidxZ48vSoshZHM3rAEt1PdsR1F +c0LcsLVFsdBCBBmvNWV9y2WpvmjNczfMk5ZyW9LX2RyZ7VjddXd9XiFhtto7VoLdhGgbFF5PbSIk +xLaZKjsBZtWyWyAUA2cnQy2vG2sdP/E7WuQmRSWesFibP6A46mC48lAxwMPZqAjpQZlaBQKBQKBQ +KBQKBQKBQYsespt8btxHT6zzHFDf3gvYYkeYM63s1c4sUXIfP56DVAL2w8eenRgumC4REoOZFdEB +AL6K5qe0YupQEwkKPKbT+EAhQZeOgffBN1hNhtwbg2+bMd+wBg14+1QqWJQt8A1/Cje5R8mg8aDb +v8PyT4/j+GtBi46n217qN7oo1jCP7Cd4LbtLTNTs7DmVwFtfWyUytvLbIDQdolUbtnfLWlspw5CC +TUTAbmERMUQwoj4YveRkFT78Zu6w2eXrLIOQuSB6a2iauzU1iGoho7yqbA/aj2cRAKDlBfDK7qZw +l93s89YjcZkHH3YvYu6Xp2M6tenMBTe802ewA4APkoL6/eEc2wtLWiV4T3abn8Y5MZTd4sU4cRhL +w1d6ejoJmhsZWLQPOACPD46DN507tr+47Zlt1JiHcJuklO7ycDOHmQo8nzkXj1jRFHUS9yxExpFe +f3seQebXiOgmHQQ8ocU6tS49vpj7/hUGKU5toWf7IGNygGqjGcis6cTaamC7oHl1Gg04lB696emH +Q3Bb4du+LbgWRbXeftl13u3ya2EaBuLec1Ks5/4iRMlPdMGnH1Q8aDb6MLWljzCxx9o/EkbM1s7a +h1/qny+Sgv8AQW/89+HmoLhQKBQKBQKBQKC30FwoOjd0DD7w7c84NOv5bi/JH0sj15vjoNU5k3aJ +Pp1tryZvAgdkr/HdvOV4nizNLClIAusEj8/tOqXGE+dA1KYI5K5AwrWk94Q5bS6xbAeBxEA7j6EC +4P8Aq6bBFVsto2ueWe2e2oAwlKFxme7N4pigICFwhDmEvEQ5g7PJQbe50Sqkirj9zjx/yaCva3T8 +04/D4fJQeC99rV1K3VPClHT2k+CWVQhK7BkSLZt74aXB3KHMVoc2SVkhs0EvqgLx1KGuocR0HQMS +/wBiHidcnKwapDuZ2lYXj638tXRx3+tw1D9Ti17fOHCgzzbOcXZ3wlt9hWPtx+cTbjctMguwSnKg +s/uuDwVze/WDpbA4gAMRTCUR0Awh8gUHc6qUflyTT5fpHtGgxndXQ43elz1AFZC857e1DMxFZQNy +cpr8Mc05T66Dwtjd10/haacNaDTzakJbu3ro8tmwUDHADlJcumMPLZT2NSXBMoU3TAUgAQ/LxOYO +QpjAGbjoqYpkkT6jG0lmnDK5w97m7uyTi2nemS9bc7sOl8WI5wJ8aS3RG4ZskLW5kV2DDxPbtgOn +Gg2i1AoLH+c/Dz0F8oFAoFAoFAoFAoFB1vlpL7Xi/IyVV+SLYHMGzs/1syfPQRaPDWQ6HzfMvVo2 +25AijdNcZTeNw5slcKkIFFolTQWZ5RjfdVzm1AbZ2F3AnDjqYNNO0AxIbn+n9IugH1WtsW5NZFJr +kXZW3bho3PMXy5uS2zya3HbLtzu+G38pQ5XDL8KZLt0ycDjyyZNbtqbYAJ74gGyjwxmfGO43FOP8 +u4ombZPMbZIjDTIobNmIxitjw2udpPoa2Y1rmZnzQ4lM2jxKcvKIagIFDsNKl7pVf+m9v7vH56Dk +FAoOByhzVpPxRIuHT975NKCxtbCrVqtfzT976KDGn11Z3FcX9I7fCofndG0kkuGHTH7SdUptIlb3 +Mp8rRxlrj6BKe8Fxe8uV9yC6CK2BjWEFu9d0ApDCAQEuhP0dXrqL5oTZJyzHVSbZ1geStTlld4vn +IlsZWlgJiObVhGN63LalcpfbdwUkhUCNv3bRhzfg1CgxRDMVkVqSKvFErStCBqQtEWc8DRhiQtjM +DQ0M7Q0bXcLgDOz/APLYUEy+gUFv9l/Gu3h9H7etBcKBQKBQKBQKBQKBQcGyh/sHOPa/+F3j5P0J ++xQRlvC/6O+9LqaStJqtSf3PbES7savrbKOTnPUB4Dw7loJYm6va9hLehhqbbdNwULb5njCftpkT +83gTV3bHQSn7plUTdTEPdYJDHTH5gdS6GAwAHZqUQhPMkh3o+GO3RLIVMGnIm5vppZslZHNvfm+7 +daBZ3RfbuJrcpx/a0MwwTcKyWiAWUNBvQnRQAQ0ECmKEx3b3uuwzu6xgxZp2+ZTa8sQB5KXleo2G +jlE3YSmHubIEU5SvsDkhfVjqDpoYoBqIAAgIh3MlfnT/AE7234fL8dB+9+qvZfy7h5/39aCg70+V +YHw+fQKDxPv86tG07pr48XSDPE2aHDJXdxl8MwDDXdmc825CcfVczTyRAxDhB4+c5ih3s6CUoAI8 +dS8pgiewXDfUP8TZmtiz1uDcpZtm6c+LnYjlB2WPkdjtN0AtnLcaMUA6Ax28v5FPw74yA5k9WyAY +e6gAAABCZriLDWJ9vOI4NgfCmO2qA4rxi1BGoVGGsnIDUbiI8roJji/SGQDq6urqJjCAeUR40ETb +cSdFjbxMKF4fkhWhnmZsFSFkFwMBwdmtywvDYpcddQEQ5iXISICHkEBCgl7JPyX9j/NoP6oFAoFA +oFAoFAoFAoLC/PzDE2BdIZC+NjK0srWLkuXOf6KaGnXWgih9XLrwRdpYXzA2zmVNcndlrW8Nk3yo +2fW0TaGnT/dH/iCR8aDIn4VrbE54n2MS7cJNWRwZ5buzyh76My50OUHN1xRFCg3Y8drg8RAX57fH +QwgIBqA/PQSQ350VpHT2v804fPrxoOKT2LYlz1A3rGWWIvGZ/CZM1d2vsVkbODs0PID/AABAQEPn +4DQRoc0+HPyFhLIS3O/Sn3TzfbJOu8gcUUJty98aGY/okAzULmYX1inZhuCbQkpbbgAUOJhoPLjp +uq8UVtPfl0eybtJgm6pp/wCKW3D3vZ3v5vrfDc0839V0HE3XrHeIHWJQSR/o8NqFWHYucdrO4qVB +2eXlFk7dPLQcWM+eLE3kMKtqaWBq2rwiSD3e9OBmfF230QbHcpiCYoyiZZNy8QRIYQ+qmvXQR40H +vPYd4XXDcKfkOd+oZlVw3l5sXCDk5RxxdHp8xaDoJbnrAlMqlRiTrLpyjyCQ7qDOGuoDbEONBKwY +o+xRppQsEcZ29lZmRtam5nZG5rI2NLO0tfFqbGtqRDpbJaEB0AvAPNpwoPkqa0ventfsP0cOzhxo +I9XXg6X8o3SwKK7mtuCF0/xS7cNO42KNh9b5cxP3370O+Omnj/tHH38O9Gmg6r6afVyxhm3HSHGW +4+VNeMs8wv8Au2+e+31S0y52aP8A6fkf+t2igzgNbqldkqF2aVwrUi38hXNvyfTQXCgUCgUCgUCg +UHW+UMtY5w5F104ybOIvC48i1/HpI8A0j5P3KCMtvc8SxA8eql0T2nwgZo7fmE4m31TE+9v7ID6/ +1oIqW6rqg7vt3ar/AO7OYZQtj3/A8b/unE//AHQ10HafRdw5gHdH1MdtOEt0DN74YsyCSZlVQkXV +9bWaWSyKsBpNjxpeiEuWz3I0Pc3EoCAmARDUKDbVsLC0xlpRR6PIm5naGduaW5mZm5pBpamdqaQH +upra2rUOQpNNOH3vAAANAAAuipKlV8OPk+X5x40FjVMLX5PJ9z7oUFclSpUg/ivy60Fd7T8fw/k0 +D8W+GlB+eyJvMP0/coPrQfL2RN5h+n7lB9aC2pfaUn9NS/F8n7VBhV6kPRe26b2SPOWIosvbc9yO +neAZhhTIV2bZgctsxjfaRj0nLYnoiJBDXUr1zcAHiFBFVxb1JNxfSM3ZZg2eZhfGzM8dxPJix6Uo +G92uW2sAEAOzy3HhJYBTkMAD+hxABDy0Er7aD1DttO8aLd7YyyM1+8I/l0GcnfumWcP6oCg94UCg +UCgUCgsEl9r7qXd3d5e2/wDLHc3evze9X1DQQfeun72e8yHvD/qA+26//tb7Ffs67P8AdD3Z+oaC +I7KO9u8lv+3f336z9y+zh837v0UHFU/evtHD3k1+L3N1+fm9HWg9pdPb3g/xx7SO7vte9u/xN459 +g+zn7M/tD9Z37w91vej+73vlp2d5fgvPxoNz83e1d12vaO9Pbe7Q5u9+5u9P5sf073X9Qa/5Hk1o +L7QU9AoKigp6BQKC2pfa9f1n83cvm8nxUD8b9r/Wfb/UnN2UFyoFBqTOvl3p/wBW/ePr78a+/bRp +3f7na69yk/S3L6PNprprw018ulB0lsj94vtjg/8A/Umnegf/AIk+y37Qu3/dHy0Gyl2Xe932ZJ/e +r/Fxp3Y0d2/4tvsR95f/AAH7Gvwmn9rUHsOgUCg//9k= + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1-- + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74-- diff --git a/actionmailbox/test/generators/mailbox_generator_test.rb b/actionmailbox/test/generators/mailbox_generator_test.rb new file mode 100644 index 0000000000000..f9677ba591270 --- /dev/null +++ b/actionmailbox/test/generators/mailbox_generator_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "test_helper" +require "rails/generators/mailbox/mailbox_generator" + +class MailboxGeneratorTest < Rails::Generators::TestCase + destination File.expand_path("../../tmp", __dir__) + setup :prepare_destination + tests Rails::Generators::MailboxGenerator + + arguments ["inbox"] + + def test_mailbox_skeleton_is_created + run_generator + + assert_file "app/mailboxes/inbox_mailbox.rb" do |mailbox| + assert_match(/class InboxMailbox < ApplicationMailbox/, mailbox) + assert_match(/def process/, mailbox) + assert_no_match(%r{# routing /something/i => :somewhere}, mailbox) + end + + assert_file "app/mailboxes/application_mailbox.rb" do |mailbox| + assert_match(/class ApplicationMailbox < ActionMailbox::Base/, mailbox) + assert_match(%r{# routing /something/i => :somewhere}, mailbox) + assert_no_match(/def process/, mailbox) + end + end + + def test_mailbox_skeleton_is_created_with_namespace + run_generator %w(inceptions/inbox -t=test_unit) + + assert_file "app/mailboxes/inceptions/inbox_mailbox.rb" do |mailbox| + assert_match(/class Inceptions::InboxMailbox < ApplicationMailbox/, mailbox) + assert_match(/def process/, mailbox) + assert_no_match(%r{# routing /something/i => :somewhere}, mailbox) + end + + assert_file "test/mailboxes/inceptions/inbox_mailbox_test.rb" do |mailbox| + assert_match(/class Inceptions::InboxMailboxTest < ActionMailbox::TestCase/, mailbox) + assert_match(/# test "receive mail" do/, mailbox) + assert_match(/# to: '"someone" ',/, mailbox) + end + + assert_file "app/mailboxes/application_mailbox.rb" do |mailbox| + assert_match(/class ApplicationMailbox < ActionMailbox::Base/, mailbox) + assert_match(%r{# routing /something/i => :somewhere}, mailbox) + assert_no_match(/def process/, mailbox) + end + end + + def test_check_class_collision + Object.const_set :InboxMailbox, Class.new + content = capture(:stderr) { run_generator } + assert_match(/The name 'InboxMailbox' is either already used in your application or reserved/, content) + ensure + Object.send :remove_const, :InboxMailbox + end + + def test_invokes_default_test_framework + run_generator %w(inbox -t=test_unit) + + assert_file "test/mailboxes/inbox_mailbox_test.rb" do |test| + assert_match(/class InboxMailboxTest < ActionMailbox::TestCase/, test) + assert_match(/# test "receive mail" do/, test) + assert_match(/# to: '"someone" ',/, test) + end + end + + def test_mailbox_on_revoke + run_generator + run_generator ["inbox"], behavior: :revoke + + assert_no_file "app/mailboxes/inbox_mailbox.rb" + end + + def test_mailbox_suffix_is_not_duplicated + run_generator %w(inbox_mailbox -t=test_unit) + + assert_no_file "app/mailboxes/inbox_mailbox_mailbox.rb" + assert_file "app/mailboxes/inbox_mailbox.rb" + + assert_no_file "test/mailboxes/inbox_mailbox_mailbox_test.rb" + assert_file "test/mailboxes/inbox_mailbox_test.rb" + end +end diff --git a/actionmailbox/test/jobs/incineration_job_test.rb b/actionmailbox/test/jobs/incineration_job_test.rb new file mode 100644 index 0000000000000..03948a32bf93f --- /dev/null +++ b/actionmailbox/test/jobs/incineration_job_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionMailbox::IncinerationJobTest < ActiveJob::TestCase + setup { @inbound_email = create_inbound_email_from_fixture("welcome.eml") } + + test "ignoring a missing inbound email" do + @inbound_email.destroy! + + perform_enqueued_jobs do + assert_nothing_raised do + ActionMailbox::IncinerationJob.perform_later @inbound_email + end + end + end +end diff --git a/actionmailbox/test/migrations_test.rb b/actionmailbox/test/migrations_test.rb new file mode 100644 index 0000000000000..eb18781e03081 --- /dev/null +++ b/actionmailbox/test/migrations_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "test_helper" +require ActionMailbox::Engine.root.join("db/migrate/20180917164000_create_action_mailbox_tables.rb").to_s + +class ActionMailbox::MigrationsTest < ActiveSupport::TestCase + setup do + @original_verbose = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + + @connection = ActiveRecord::Base.lease_connection + @original_options = Rails.configuration.generators.options.deep_dup + end + + teardown do + Rails.configuration.generators.options = @original_options + rerun_migration + ActiveRecord::Migration.verbose = @original_verbose + end + + test "migration creates tables with default primary key type" do + action_mailbox_tables.each do |table| + assert_equal :integer, primary_key(table).type + end + end + + test "migration creates tables with configured primary key type" do + Rails.configuration.generators do |g| + g.orm :active_record, primary_key_type: :string + end + + rerun_migration + + action_mailbox_tables.each do |table| + assert_equal :string, primary_key(table).type + end + end + + private + def rerun_migration + CreateActionMailboxTables.migrate(:down) + CreateActionMailboxTables.migrate(:up) + end + + def action_mailbox_tables + @action_mailbox_tables ||= ActionMailbox::Record.descendants.map { |klass| klass.table_name.to_sym } + end + + def primary_key(table) + @connection.columns(table).find { |c| c.name == "id" } + end +end diff --git a/actionmailbox/test/models/table_name_test.rb b/actionmailbox/test/models/table_name_test.rb new file mode 100644 index 0000000000000..0b082d1bd4867 --- /dev/null +++ b/actionmailbox/test/models/table_name_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActionMailbox::TableNameTest < ActiveSupport::TestCase + setup do + @old_prefix = ActiveRecord::Base.table_name_prefix + @old_suffix = ActiveRecord::Base.table_name_suffix + + ActiveRecord::Base.table_name_prefix = @prefix = "abc_" + ActiveRecord::Base.table_name_suffix = @suffix = "_xyz" + + @models = [ActionMailbox::InboundEmail] + @models.map(&:reset_table_name) + end + + teardown do + ActiveRecord::Base.table_name_prefix = @old_prefix + ActiveRecord::Base.table_name_suffix = @old_suffix + + @models.map(&:reset_table_name) + end + + test "prefix and suffix are added to the Action Mailbox tables' name" do + assert_equal( + "#{@prefix}action_mailbox_inbound_emails#{@suffix}", + ActionMailbox::InboundEmail.table_name + ) + end +end diff --git a/actionmailbox/test/test_helper.rb b/actionmailbox/test/test_helper.rb new file mode 100644 index 0000000000000..2f4edb8282d49 --- /dev/null +++ b/actionmailbox/test/test_helper.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative "../../tools/strict_warnings" + +ENV["RAILS_ENV"] = "test" +ENV["RAILS_INBOUND_EMAIL_PASSWORD"] = "tbsy84uSV1Kt3ZJZELY2TmShPRs91E3yL4tzf96297vBCkDWgL" + +require_relative "../test/dummy/config/environment" +ActiveRecord::Migrator.migrations_paths = [ File.expand_path("../test/dummy/db/migrate", __dir__) ] +require "rails/test_help" + +require "webmock/minitest" + +require "rails/test_unit/reporter" +Rails::TestUnitReporter.executable = "bin/test" + +if ActiveSupport::TestCase.respond_to?(:fixture_paths=) + ActiveSupport::TestCase.fixture_paths = [File.expand_path("fixtures", __dir__)] + ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths + ActiveSupport::TestCase.file_fixture_path = File.expand_path("fixtures", __dir__) + "/files" + ActiveSupport::TestCase.fixtures :all +end + +require "action_mailbox/test_helper" + +class ActiveSupport::TestCase + include ActionMailbox::TestHelper, ActiveJob::TestHelper +end + +class ActionDispatch::IntegrationTest + private + def credentials + ActionController::HttpAuthentication::Basic.encode_credentials "actionmailbox", ENV["RAILS_INBOUND_EMAIL_PASSWORD"] + end + + def switch_password_to(new_password) + previous_password, ENV["RAILS_INBOUND_EMAIL_PASSWORD"] = ENV["RAILS_INBOUND_EMAIL_PASSWORD"], new_password + yield + ensure + ENV["RAILS_INBOUND_EMAIL_PASSWORD"] = previous_password + end +end + +if ARGV.include?("-v") + ActiveRecord::Base.logger = Logger.new(STDOUT) + ActiveJob::Base.logger = Logger.new(STDOUT) +end + +class BounceMailer < ActionMailer::Base + def bounce(to:) + mail from: "receiver@example.com", to: to, subject: "Your email was not delivered" do |format| + format.html { render plain: "Sorry!" } + end + end +end + +require_relative "../../tools/test_common" diff --git a/actionmailbox/test/unit/inbound_email/incineration_test.rb b/actionmailbox/test/unit/inbound_email/incineration_test.rb new file mode 100644 index 0000000000000..54488349fda0a --- /dev/null +++ b/actionmailbox/test/unit/inbound_email/incineration_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class ActionMailbox::InboundEmail::IncinerationTest < ActiveSupport::TestCase + test "incinerating 30 days after delivery" do + freeze_time + + assert_enqueued_with job: ActionMailbox::IncinerationJob, at: 30.days.from_now do + create_inbound_email_from_fixture("welcome.eml").delivered! + end + + travel 30.days + + assert_difference -> { ActionMailbox::InboundEmail.count }, -1 do + perform_enqueued_jobs only: ActionMailbox::IncinerationJob + end + end + + test "incinerating 30 days after bounce" do + freeze_time + + assert_enqueued_with job: ActionMailbox::IncinerationJob, at: 30.days.from_now do + create_inbound_email_from_fixture("welcome.eml").bounced! + end + + travel 30.days + + assert_difference -> { ActionMailbox::InboundEmail.count }, -1 do + perform_enqueued_jobs only: ActionMailbox::IncinerationJob + end + end + + test "incinerating 30 days after failure" do + freeze_time + + assert_enqueued_with job: ActionMailbox::IncinerationJob, at: 30.days.from_now do + create_inbound_email_from_fixture("welcome.eml").failed! + end + + travel 30.days + + assert_difference -> { ActionMailbox::InboundEmail.count }, -1 do + perform_enqueued_jobs only: ActionMailbox::IncinerationJob + end + end + + test "skipping incineration" do + original, ActionMailbox.incinerate = ActionMailbox.incinerate, false + + assert_no_enqueued_jobs only: ActionMailbox::IncinerationJob do + create_inbound_email_from_fixture("welcome.eml").delivered! + end + ensure + ActionMailbox.incinerate = original + end +end diff --git a/actionmailbox/test/unit/inbound_email/message_id_test.rb b/actionmailbox/test/unit/inbound_email/message_id_test.rb new file mode 100644 index 0000000000000..af467a8d45466 --- /dev/null +++ b/actionmailbox/test/unit/inbound_email/message_id_test.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class ActionMailbox::InboundEmail::MessageIdTest < ActiveSupport::TestCase + test "message id is extracted from raw email" do + inbound_email = create_inbound_email_from_fixture("welcome.eml") + assert_equal "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", inbound_email.message_id + end + + test "message id is generated if its missing" do + inbound_email = create_inbound_email_from_source "Date: Fri, 28 Sep 2018 11:08:55 -0700\r\nTo: a@example.com\r\nMime-Version: 1.0\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHello!" + assert_not_nil inbound_email.message_id + end +end diff --git a/actionmailbox/test/unit/inbound_email_test.rb b/actionmailbox/test/unit/inbound_email_test.rb new file mode 100644 index 0000000000000..264209c118192 --- /dev/null +++ b/actionmailbox/test/unit/inbound_email_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require_relative "../test_helper" +require "minitest/mock" + +module ActionMailbox + class InboundEmailTest < ActiveSupport::TestCase + test "mail provides the parsed source" do + assert_equal "Discussion: Let's debate these attachments", create_inbound_email_from_fixture("welcome.eml").mail.subject + end + + test "source returns the contents of the raw email" do + assert_equal file_fixture("welcome.eml").read, create_inbound_email_from_fixture("welcome.eml").source + end + + test "email with message id is processed only once when received multiple times" do + mail = Mail.from_source(file_fixture("welcome.eml").read) + assert mail.message_id + + inbound_email_1 = create_inbound_email_from_source(mail.to_s) + assert inbound_email_1 + + inbound_email_2 = create_inbound_email_from_source(mail.to_s) + assert_nil inbound_email_2 + end + + test "email with missing message id is processed only once when received multiple times" do + mail = Mail.from_source("Date: Fri, 28 Sep 2018 11:08:55 -0700\r\nTo: a@example.com\r\nMime-Version: 1.0\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHello!") + assert_nil mail.message_id + + inbound_email_1 = create_inbound_email_from_source(mail.to_s) + assert inbound_email_1 + + inbound_email_2 = create_inbound_email_from_source(mail.to_s) + assert_nil inbound_email_2 + end + + test "error on upload doesn't leave behind a pending inbound email" do + ActiveStorage::Blob.service.stub(:upload, -> { raise "Boom!" }) do + assert_no_difference -> { ActionMailbox::InboundEmail.count } do + assert_raises do + create_inbound_email_from_fixture "welcome.eml" + end + end + end + end + + test "email gets saved to the configured storage service" do + ActionMailbox.storage_service = :test_email + + assert_equal(:test_email, ActionMailbox.storage_service) + + email = create_inbound_email_from_fixture("welcome.eml") + + storage_service = ActiveStorage::Blob.services.fetch(ActionMailbox.storage_service) + raw = email.raw_email_blob + + # Not present in the main storage + assert_not(ActiveStorage::Blob.service.exist?(raw.key)) + # Present in the email storage + assert(storage_service.exist?(raw.key)) + ensure + ActionMailbox.storage_service = nil + end + + test "email gets saved to the default storage service, even if it gets changed" do + default_service = ActiveStorage::Blob.service + ActiveStorage::Blob.service = ActiveStorage::Blob.services.fetch(:test_email) + + # Doesn't change ActionMailbox.storage_service + assert_nil(ActionMailbox.storage_service) + + email = create_inbound_email_from_fixture("welcome.eml") + raw = email.raw_email_blob + + # Not present in the (previously) default storage + assert_not(default_service.exist?(raw.key)) + # Present in the current default storage (email) + assert(ActiveStorage::Blob.service.exist?(raw.key)) + ensure + ActiveStorage::Blob.service = default_service + end + end +end diff --git a/actionmailbox/test/unit/mail_ext/address_equality_test.rb b/actionmailbox/test/unit/mail_ext/address_equality_test.rb new file mode 100644 index 0000000000000..e4426aeae9594 --- /dev/null +++ b/actionmailbox/test/unit/mail_ext/address_equality_test.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module MailExt + class AddressEqualityTest < ActiveSupport::TestCase + test "two addresses with the same address are equal" do + assert_equal Mail::Address.new("david@basecamp.com"), Mail::Address.new("david@basecamp.com") + end + end +end diff --git a/actionmailbox/test/unit/mail_ext/address_wrapping_test.rb b/actionmailbox/test/unit/mail_ext/address_wrapping_test.rb new file mode 100644 index 0000000000000..c4eb1328efa69 --- /dev/null +++ b/actionmailbox/test/unit/mail_ext/address_wrapping_test.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module MailExt + class AddressWrappingTest < ActiveSupport::TestCase + test "wrap" do + needing_wrapping = Mail::Address.wrap("david@basecamp.com") + wrapping_not_needed = Mail::Address.wrap(Mail::Address.new("david@basecamp.com")) + assert_equal needing_wrapping.address, wrapping_not_needed.address + end + end +end diff --git a/actionmailbox/test/unit/mail_ext/addresses_test.rb b/actionmailbox/test/unit/mail_ext/addresses_test.rb new file mode 100644 index 0000000000000..92ea75a071ccf --- /dev/null +++ b/actionmailbox/test/unit/mail_ext/addresses_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +module MailExt + class AddressesTest < ActiveSupport::TestCase + setup do + @mail = Mail.new \ + from: "sally@example.com", + reply_to: "sarah@example.com", + to: "david@basecamp.com", + cc: "jason@basecamp.com", + bcc: "andrea@basecamp.com", + x_original_to: "ryan@basecamp.com", + x_forwarded_to: "jane@example.com" + end + + test "from address uses address object" do + assert_equal "example.com", @mail.from_address.domain + end + + test "reply to address uses address object" do + assert_equal "example.com", @mail.reply_to_address.domain + end + + test "recipients include everyone from to, cc, bcc, x-original-to, and x-forwarded-to" do + assert_equal %w[ david@basecamp.com jason@basecamp.com andrea@basecamp.com ryan@basecamp.com jane@example.com ], @mail.recipients + end + + test "recipients addresses use address objects" do + assert_equal "basecamp.com", @mail.recipients_addresses.first.domain + end + + test "to addresses use address objects" do + assert_equal "basecamp.com", @mail.to_addresses.first.domain + end + + test "cc addresses use address objects" do + assert_equal "basecamp.com", @mail.cc_addresses.first.domain + end + + test "bcc addresses use address objects" do + assert_equal "basecamp.com", @mail.bcc_addresses.first.domain + end + + test "x_original_to addresses use address objects" do + assert_equal "basecamp.com", @mail.x_original_to_addresses.first.domain + end + + test "x_forwarded_to addresses use address objects" do + assert_equal "example.com", @mail.x_forwarded_to_addresses.first.domain + end + end +end diff --git a/actionmailbox/test/unit/mailbox/bouncing_test.rb b/actionmailbox/test/unit/mailbox/bouncing_test.rb new file mode 100644 index 0000000000000..667ec62c04c31 --- /dev/null +++ b/actionmailbox/test/unit/mailbox/bouncing_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class BouncingWithReplyMailbox < ActionMailbox::Base + def process + bounce_with BounceMailer.bounce(to: mail.from) + end +end + +class BouncingWithImmediateReplyMailbox < ActionMailbox::Base + def process + bounce_now_with BounceMailer.bounce(to: mail.from) + end +end + +class ActionMailbox::Base::BouncingTest < ActiveSupport::TestCase + include ActionMailer::TestHelper + + setup do + @inbound_email = create_inbound_email_from_mail \ + from: "sender@example.com", to: "replies@example.com", subject: "Bounce me" + end + + teardown do + ActionMailer::Base.deliveries.clear + end + + test "bouncing with a reply" do + perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do + BouncingWithReplyMailbox.receive @inbound_email + end + + assert_predicate @inbound_email, :bounced? + assert_emails 1 + + mail = ActionMailer::Base.deliveries.last + assert_equal %w[ sender@example.com ], mail.to + assert_equal "Your email was not delivered", mail.subject + end + + test "bouncing now with a reply" do + assert_no_enqueued_emails do + BouncingWithImmediateReplyMailbox.receive @inbound_email + end + + assert_predicate @inbound_email, :bounced? + assert_emails 1 + + mail = ActionMailer::Base.deliveries.last + assert_equal %w[ sender@example.com ], mail.to + assert_equal "Your email was not delivered", mail.subject + end +end diff --git a/actionmailbox/test/unit/mailbox/callbacks_test.rb b/actionmailbox/test/unit/mailbox/callbacks_test.rb new file mode 100644 index 0000000000000..1917aa3250da0 --- /dev/null +++ b/actionmailbox/test/unit/mailbox/callbacks_test.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class CallbackMailbox < ActionMailbox::Base + before_processing { $before_processing = "Ran that!" } + after_processing { $after_processing = "Ran that too!" } + around_processing ->(r, block) { block.call; $around_processing = "Ran that as well!" } + + def process + $processed = mail.subject + end +end + +class BouncingCallbackMailbox < ActionMailbox::Base + before_processing { $before_processing = [ "Pre-bounce" ] } + + before_processing do + bounce_with BounceMailer.bounce(to: mail.from) + $before_processing << "Bounce" + end + + before_processing { $before_processing << "Post-bounce" } + + after_processing { $after_processing = true } + + def process + $processed = true + end +end + +class DiscardingCallbackMailbox < ActionMailbox::Base + before_processing { $before_processing = [ "Pre-discard" ] } + + before_processing do + delivered! + $before_processing << "Discard" + end + + before_processing { $before_processing << "Post-discard" } + + after_processing { $after_processing = true } + + def process + $processed = true + end +end + +class ActionMailbox::Base::CallbacksTest < ActiveSupport::TestCase + setup do + $before_processing = $after_processing = $around_processing = $processed = false + @inbound_email = create_inbound_email_from_fixture("welcome.eml") + end + + test "all callback types" do + CallbackMailbox.receive @inbound_email + assert_equal "Ran that!", $before_processing + assert_equal "Ran that too!", $after_processing + assert_equal "Ran that as well!", $around_processing + end + + test "bouncing in a callback terminates processing" do + BouncingCallbackMailbox.receive @inbound_email + assert_predicate @inbound_email, :bounced? + assert_equal [ "Pre-bounce", "Bounce" ], $before_processing + assert_not $processed + assert_not $after_processing + end + + test "marking the inbound email as delivered in a callback terminates processing" do + DiscardingCallbackMailbox.receive @inbound_email + assert_predicate @inbound_email, :delivered? + assert_equal [ "Pre-discard", "Discard" ], $before_processing + assert_not $processed + assert_not $after_processing + end +end diff --git a/actionmailbox/test/unit/mailbox/notifications_test.rb b/actionmailbox/test/unit/mailbox/notifications_test.rb new file mode 100644 index 0000000000000..655ed4560258f --- /dev/null +++ b/actionmailbox/test/unit/mailbox/notifications_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class RepliesMailbox < ActionMailbox::Base +end + +class ActionMailbox::Base::NotificationsTest < ActiveSupport::TestCase + test "instruments processing" do + mailbox = RepliesMailbox.new(create_inbound_email_from_fixture("welcome.eml")) + expected_payload = { + mailbox:, + inbound_email: { + id: 1, + message_id: "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", + status: "processing" + } + } + + assert_notifications_count("process.action_mailbox", 1) do + assert_notification("process.action_mailbox", expected_payload) do + mailbox.perform_processing + end + end + end +end diff --git a/actionmailbox/test/unit/mailbox/routing_test.rb b/actionmailbox/test/unit/mailbox/routing_test.rb new file mode 100644 index 0000000000000..8302b1d5ccce6 --- /dev/null +++ b/actionmailbox/test/unit/mailbox/routing_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class ApplicationMailbox < ActionMailbox::Base + routing "replies@example.com" => :replies +end + +class RepliesMailbox < ActionMailbox::Base + def process + $processed = mail.subject + end +end + +class ActionMailbox::Base::RoutingTest < ActiveSupport::TestCase + setup do + $processed = false + end + + test "string routing" do + ApplicationMailbox.route create_inbound_email_from_fixture("welcome.eml") + assert_equal "Discussion: Let's debate these attachments", $processed + end + + test "delayed routing" do + perform_enqueued_jobs only: ActionMailbox::RoutingJob do + create_inbound_email_from_fixture "welcome.eml", status: :pending + assert_equal "Discussion: Let's debate these attachments", $processed + end + end + + test "mailbox_for" do + inbound_email = create_inbound_email_from_fixture "welcome.eml", status: :pending + assert_equal RepliesMailbox, ApplicationMailbox.mailbox_for(inbound_email) + end +end diff --git a/actionmailbox/test/unit/mailbox/state_test.rb b/actionmailbox/test/unit/mailbox/state_test.rb new file mode 100644 index 0000000000000..cf1fd5441ea72 --- /dev/null +++ b/actionmailbox/test/unit/mailbox/state_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class SuccessfulMailbox < ActionMailbox::Base + def process + $processed = mail.subject + end +end + +class UnsuccessfulMailbox < ActionMailbox::Base + rescue_from(RuntimeError) { $processed = :failure } + + def process + raise "No way!" + end +end + +class BouncingMailbox < ActionMailbox::Base + def process + $processed = :bounced + bounced! + end +end + + +class ActionMailbox::Base::StateTest < ActiveSupport::TestCase + setup do + $processed = false + @inbound_email = create_inbound_email_from_mail \ + to: "replies@example.com", subject: "I was processed" + end + + test "successful mailbox processing leaves inbound email in delivered state" do + SuccessfulMailbox.receive @inbound_email + assert_predicate @inbound_email, :delivered? + assert_equal "I was processed", $processed + end + + test "unsuccessful mailbox processing leaves inbound email in failed state" do + UnsuccessfulMailbox.receive @inbound_email + assert_predicate @inbound_email, :failed? + assert_equal :failure, $processed + end + + test "bounced inbound emails are not delivered" do + BouncingMailbox.receive @inbound_email + assert_predicate @inbound_email, :bounced? + assert_equal :bounced, $processed + end +end diff --git a/actionmailbox/test/unit/relayer_test.rb b/actionmailbox/test/unit/relayer_test.rb new file mode 100644 index 0000000000000..89701d0baa111 --- /dev/null +++ b/actionmailbox/test/unit/relayer_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +require "action_mailbox/relayer" + +module ActionMailbox + class RelayerTest < ActiveSupport::TestCase + URL = "https://example.com/rails/action_mailbox/relay/inbound_emails" + INGRESS_PASSWORD = "secret" + + setup do + @relayer = ActionMailbox::Relayer.new(url: URL, password: INGRESS_PASSWORD) + end + + test "successfully relaying an email" do + stub_request(:post, URL).to_return status: 204 + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "2.0.0", result.status_code + assert_equal "Successfully relayed message to ingress", result.message + assert_predicate result, :success? + assert_not result.failure? + + assert_requested :post, URL, body: file_fixture("welcome.eml").read, + basic_auth: [ "actionmailbox", INGRESS_PASSWORD ], + headers: { "Content-Type" => "message/rfc822", "User-Agent" => /\AAction Mailbox relayer v\d+\./ } + end + + test "unsuccessfully relaying with invalid credentials" do + stub_request(:post, URL).to_return status: 401 + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.7.0", result.status_code + assert_equal "Invalid credentials for ingress", result.message + assert_not result.success? + assert_predicate result, :failure? + end + + test "unsuccessfully relaying due to an unspecified server error" do + stub_request(:post, URL).to_return status: 500 + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.0.0", result.status_code + assert_equal "HTTP 500", result.message + assert_not result.success? + assert_predicate result, :failure? + end + + test "unsuccessfully relaying due to a gateway timeout" do + stub_request(:post, URL).to_return status: 504 + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.0.0", result.status_code + assert_equal "HTTP 504", result.message + assert_not result.success? + assert_predicate result, :failure? + end + + test "unsuccessfully relaying due to ECONNRESET" do + stub_request(:post, URL).to_raise Errno::ECONNRESET.new + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.4.2", result.status_code + assert_equal "Network error relaying to ingress: Connection reset by peer", result.message + assert_not result.success? + assert_predicate result, :failure? + end + + test "unsuccessfully relaying due to connection failure" do + stub_request(:post, URL).to_raise SocketError.new("Failed to open TCP connection to example.com:443") + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.4.2", result.status_code + assert_equal "Network error relaying to ingress: Failed to open TCP connection to example.com:443", result.message + assert_not result.success? + assert_predicate result, :failure? + end + + test "unsuccessfully relaying due to client-side timeout" do + stub_request(:post, URL).to_timeout + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.4.2", result.status_code + assert_equal "Timed out relaying to ingress", result.message + assert_not result.success? + assert_predicate result, :failure? + end + + test "unsuccessfully relaying due to an unhandled exception" do + stub_request(:post, URL).to_raise StandardError.new("Something went wrong") + + result = @relayer.relay(file_fixture("welcome.eml").read) + assert_equal "4.0.0", result.status_code + assert_equal "Error relaying to ingress: Something went wrong", result.message + assert_not result.success? + assert_predicate result, :failure? + end + end +end diff --git a/actionmailbox/test/unit/router_test.rb b/actionmailbox/test/unit/router_test.rb new file mode 100644 index 0000000000000..d1f46c18c8bdd --- /dev/null +++ b/actionmailbox/test/unit/router_test.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +class RootMailbox < ActionMailbox::Base + def process + $processed_by = self.class.to_s + $processed_mail = mail + end +end + +class FirstMailbox < RootMailbox +end + +class SecondMailbox < RootMailbox +end + +module Nested + class FirstMailbox < RootMailbox + end +end + +class FirstMailboxAddress + def match?(inbound_email) + inbound_email.mail.to.include?("replies-class@example.com") + end +end + +module ActionMailbox + class RouterTest < ActiveSupport::TestCase + setup do + @router = ActionMailbox::Router.new + $processed_by = $processed_mail = nil + end + + test "single string route" do + @router.add_routes("first@example.com" => :first) + + inbound_email = create_inbound_email_from_mail(to: "first@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "FirstMailbox", $processed_by + assert_equal inbound_email.mail, $processed_mail + end + + test "single string routing on cc" do + @router.add_routes("first@example.com" => :first) + + inbound_email = create_inbound_email_from_mail(to: "someone@example.com", cc: "first@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "FirstMailbox", $processed_by + assert_equal inbound_email.mail, $processed_mail + end + + test "single string routing on bcc" do + @router.add_routes("first@example.com" => :first) + + inbound_email = create_inbound_email_from_mail(to: "someone@example.com", bcc: "first@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "FirstMailbox", $processed_by + assert_equal inbound_email.mail, $processed_mail + end + + test "single string routing case-insensitively" do + @router.add_routes("first@example.com" => :first) + + inbound_email = create_inbound_email_from_mail(to: "FIRST@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "FirstMailbox", $processed_by + assert_equal inbound_email.mail, $processed_mail + end + + test "multiple string routes" do + @router.add_routes("first@example.com" => :first, "second@example.com" => :second) + + inbound_email = create_inbound_email_from_mail(to: "first@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "FirstMailbox", $processed_by + assert_equal inbound_email.mail, $processed_mail + + inbound_email = create_inbound_email_from_mail(to: "second@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "SecondMailbox", $processed_by + assert_equal inbound_email.mail, $processed_mail + end + + test "single regexp route" do + @router.add_routes(/replies-\w+@example.com/ => :first, "replies-nowhere@example.com" => :second) + + inbound_email = create_inbound_email_from_mail(to: "replies-okay@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "FirstMailbox", $processed_by + end + + test "single proc route" do + @router.add_route \ + ->(inbound_email) { inbound_email.mail.to.include?("replies-proc@example.com") }, + to: :second + + @router.route create_inbound_email_from_mail(to: "replies-proc@example.com", subject: "This is a reply") + assert_equal "SecondMailbox", $processed_by + end + + test "address class route" do + @router.add_route FirstMailboxAddress.new, to: :first + @router.route create_inbound_email_from_mail(to: "replies-class@example.com", subject: "This is a reply") + assert_equal "FirstMailbox", $processed_by + end + + test "string route to nested mailbox" do + @router.add_route "first@example.com", to: "nested/first" + + inbound_email = create_inbound_email_from_mail(to: "first@example.com", subject: "This is a reply") + @router.route inbound_email + assert_equal "Nested::FirstMailbox", $processed_by + end + + test "all as the only route" do + @router.add_route :all, to: :first + @router.route create_inbound_email_from_mail(to: "replies-class@example.com", subject: "This is a reply") + assert_equal "FirstMailbox", $processed_by + end + + test "all as the second route" do + @router.add_route FirstMailboxAddress.new, to: :first + @router.add_route :all, to: :second + + @router.route create_inbound_email_from_mail(to: "replies-class@example.com", subject: "This is a reply") + assert_equal "FirstMailbox", $processed_by + + @router.route create_inbound_email_from_mail(to: "elsewhere@example.com", subject: "This is a reply") + assert_equal "SecondMailbox", $processed_by + end + + test "missing route" do + inbound_email = create_inbound_email_from_mail(to: "going-nowhere@example.com", subject: "This is a reply") + assert_raises(ActionMailbox::Router::RoutingError) do + @router.route inbound_email + end + assert_predicate inbound_email, :bounced? + end + + test "invalid address" do + error = assert_raises(ArgumentError) do + @router.add_route Array.new, to: :first + end + assert_equal "Expected a Symbol, String, Regexp, Proc, or matchable, got []", error.message + end + + test "single string mailbox_for" do + @router.add_routes("first@example.com" => :first) + + inbound_email = create_inbound_email_from_mail(to: "first@example.com", subject: "This is a reply") + assert_equal FirstMailbox, @router.mailbox_for(inbound_email) + end + + test "mailbox_for with no matches" do + @router.add_routes("first@example.com" => :first) + + inbound_email = create_inbound_email_from_mail(to: "second@example.com", subject: "This is a reply") + assert_nil @router.mailbox_for(inbound_email) + end + end +end diff --git a/actionmailbox/test/unit/test_helper_test.rb b/actionmailbox/test/unit/test_helper_test.rb new file mode 100644 index 0000000000000..d392c80133789 --- /dev/null +++ b/actionmailbox/test/unit/test_helper_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +module ActionMailbox + class TestHelperTest < ActiveSupport::TestCase + test "multi-part mail can be built in tests using a block" do + inbound_email = create_inbound_email_from_mail do + to "test@example.com" + from "hello@example.com" + + text_part do + body "Hello, world" + end + + html_part do + body "

Hello, world

" + end + end + + mail = inbound_email.mail + + expected_mail_text_part = <<~TEXT.chomp + Content-Type: text/plain;\r + charset=UTF-8\r + Content-Transfer-Encoding: 7bit\r + \r + Hello, world + TEXT + + expected_mail_html_part = <<~HTML.chomp + Content-Type: text/html;\r + charset=UTF-8\r + Content-Transfer-Encoding: 7bit\r + \r +

Hello, world

+ HTML + + assert_equal 2, mail.parts.count + assert_equal expected_mail_text_part, mail.text_part.to_s + assert_equal expected_mail_html_part, mail.html_part.to_s + end + end +end diff --git a/actionmailer/CHANGELOG.md b/actionmailer/CHANGELOG.md index da5d5c4086b2b..96299d1fedd08 100644 --- a/actionmailer/CHANGELOG.md +++ b/actionmailer/CHANGELOG.md @@ -1,501 +1,2 @@ -## Rails 4.0.0 (unreleased) ## -* No changes - - -## Rails 3.2.1 (January 26, 2012) ## - -* No changes. - - -## Rails 3.2.0 (January 20, 2012) ## - -* Upgrade mail version to 2.4.0 *ML* - -* Remove Old ActionMailer API *Josh Kalderimis* - - -## Rails 3.1.3 (November 20, 2011) ## - -* No changes - - -## Rails 3.1.2 (November 18, 2011) ## - -* No changes - - -## Rails 3.1.1 (October 7, 2011) ## - -* No changes - - -## Rails 3.1.0 (August 30, 2011) ## - -* No changes - - -## Rails 3.0.11 (November 18, 2011) ## - -* No changes. - - -## Rails 3.0.10 (August 16, 2011) ## - -* No changes. - - -## Rails 3.0.9 (June 16, 2011) ## - -* No changes. - - -## Rails 3.0.8 (June 7, 2011) ## - -* Mail dependency increased to 2.2.19 - - -## Rails 3.0.7 (April 18, 2011) ## - -* remove AM delegating register_observer and register_interceptor to Mail *Josh Kalderimis* - - -## Rails 3.0.6 (April 5, 2011) ## - -* Don't allow i18n to change the minor version, version now set to ~> 0.5.0 *Santiago Pastorino* - - -## Rails 3.0.5 (February 26, 2011) ## - -* No changes. - - -## Rails 3.0.4 (February 8, 2011) ## - -* No changes. - - -## Rails 3.0.3 (November 16, 2010) ## - -* No changes. - - -## Rails 3.0.2 (November 15, 2010) ## - -* No changes - - -## Rails 3.0.1 (October 15, 2010) ## - -* No Changes, just a version bump. - - -## Rails 3.0.0 (August 29, 2010) ## - -* subject is automatically looked up on I18n using mailer_name and action_name as scope as in t(".subject") *JK* - -* Changed encoding behaviour of mail, so updated tests in actionmailer and bumped mail version to 2.2.1 *ML* - -* Added ability to pass Proc objects to the defaults hash *ML* - -* Removed all quoting.rb type files from ActionMailer and put Mail 2.2.0 in instead *ML* - -* Lot of updates to various test cases that now work better with the new Mail and so have different expectations - -* Added interceptors and observers from Mail *ML* - - ActionMailer::Base.register_interceptor calls Mail.register_interceptor - ActionMailer::Base.register_observer calls Mail.register_observer - -* Mail::Part now no longer has nil as a default charset, it is always set to something, and defaults to UTF-8 - -* Added explict setting of charset in set_fields! method to make sure Mail has the user defined default - -* Removed quoting.rb and refactored for Mail to take responsibility of all quoting and auto encoding requirements for the header. - -* Fixed several tests which had incorrect encoding. - -* Changed all utf-8 to UTF-8 for consistency - -* Whole new API added with tests. See base.rb for full details. Old API is deprecated. - -* The Mail::Message class has helped methods for all the field types that return 'common' defaults for the common use case, so to get the subject, mail.subject will give you a string, mail.date will give you a DateTime object, mail.from will give you an array of address specs (mikel@test.lindsaar.net) etc. If you want to access the field object itself, call mail[:field_name] which will return the field object you want, which you can then chain, like mail[:from].formatted - -* Mail#content_type now returns the content_type field as a string. If you want the mime type of a mail, then you call Mail#mime_type (eg, text/plain), if you want the parameters of the content type field, you call Mail#content_type_parameters which gives you a hash, eg {'format' => 'flowed', 'charset' => 'utf-8'} - -* ActionMailer::Base :default_implicit_parts_order now is in the sequence of the order you want, no reversing of ordering takes place. The default order now is text/plain, then text/enriched, then text/html and then any other part that is not one of these three. - -* Mail does not have "quoted_body", "quoted_subject" etc. All of these are accessed via body.encoded, subject.encoded etc - -* Every object in a Mail object returns an object, never a string. So Mail.body returns a Mail::Body class object, need to call #encoded or #decoded to get the string you want. -* Mail::Message#set_content_type does not exist, it is simply Mail::Message#content_type - -* Every mail message gets a unique message_id unless you specify one, had to change all the tests that check for equality with expected.encoded == actual.encoded to first replace their message_ids with control values - -* Mail now has a proper concept of parts, remove the ActionMailer::Part and ActionMailer::PartContainer classes - -* Calling #encoded on any object returns it as a string ready to go into the output stream of an email, this means it includes the \r\n at the end of the lines and the object is pre-wrapped with \r\n\t if it is a header field. Also, the "encoded" value includes the field name if it is a header field. - -* Attachments are only the actual attachment, with filename etc. A part contains an attachment. The part has the content_type etc. So attachments.last.content_type is invalid. But parts.last.content_type - -* There is no idea of a "sub_head" in Mail. A part is just a Message with some extra functionality, so it just has a "header" like a normal mail message - - -## 2.3.2 Final (March 15, 2009) ## - -* Fixed that ActionMailer should send correctly formatted Return-Path in MAIL FROM for SMTP #1842 *Matt Jones* - -* Fixed RFC-2045 quoted-printable bug #1421 *squadette* - -* Fixed that no body charset would be set when there are attachments present #740 *Paweł Kondzior* - - -## 2.2.1 RC2 (November 14th, 2008) ## - -* Turn on STARTTLS if it is available in Net::SMTP (added in Ruby 1.8.7) and the SMTP server supports it (This is required for Gmail's SMTP server) #1336 *Grant Hollingworth* - - -## 2.2.0 RC1 (October 24th, 2008) ## - -* Add layout functionality to mailers *Pratik Naik* - - Mailer layouts behaves just like controller layouts, except layout names need to - have '_mailer' postfix for them to be automatically picked up. - - -## 2.1.0 (May 31st, 2008) ## - -* Fixed that a return-path header would be ignored #7572 *joost* - -* Less verbose mail logging: just recipients for :info log level; the whole email for :debug only. #8000 *iaddict, Tarmo Tänav* - -* Updated TMail to version 1.2.1 *Mikel Lindsaar* - -* Fixed that you don't have to call super in ActionMailer::TestCase#setup #10406 *jamesgolick* - - -## 2.0.2 (December 16th, 2007) ## - -* Included in Rails 2.0.2 - - -## 2.0.1 (December 7th, 2007) ## - -* Update ActionMailer so it treats ActionView the same way that ActionController does. Closes #10244 *Rick Olson* - - * Pass the template_root as an array as ActionView's view_path - * Request templates with the "#{mailer_name}/#{action}" as opposed to just "#{action}" - -* Fixed that partials would be broken when using text.plain.erb as the extension #10130 *java* - -* Update README to use new smtp settings configuration API. Closes #10060 *psq* - -* Allow ActionMailer subclasses to individually set their delivery method (so two subclasses can have different delivery methods) #10033 *Zach Dennis* - -* Update TMail to v1.1.0. Use an updated version of TMail if available. *Mikel Lindsaar* - -* Introduce a new base test class for testing Mailers. ActionMailer::TestCase *Michael Koziarski* - -* Fix silent failure of rxml templates. #9879 *jstewart* - -* Fix attachment decoding when using the TMail C extension. #7861 *orangechicken* - -* Increase mail delivery test coverage. #8692 *Kamal Fariz Mahyuddin* - -* Register alternative template engines using ActionMailer::Base.register_template_extension('haml'). #7534 *cwd, Josh Peek* - -* Only load ActionController::UrlWriter if ActionController is present *Rick Olson* - -* Make sure parsed emails recognized attachments nested inside multipart parts. #6714 *Jamis Buck* - -* Allow mailer actions named send by using __send__ internally. #6467 *iGEL* - -* Add assert_emails and assert_no_emails to test the number of emails delivered. #6479 *Jonathan Viney* - # Assert total number of emails delivered: - assert_emails 0 - ContactMailer.deliver_contact - assert_emails 1 - - # Assert number of emails delivered within a block: - assert_emails 1 do - post :signup, :name => 'Jonathan' - end - - -## 1.3.3 (March 12th, 2007) ## - -* Depend on Action Pack 1.13.3 - - -## 1.3.2 (February 5th, 2007) ## - -* Deprecate server_settings renaming it to smtp_settings, add sendmail_settings to allow you to override the arguments to and location of the sendmail executable. *Michael Koziarski* - - -## 1.3.1 (January 16th, 2007) ## - -* Depend on Action Pack 1.13.1 - - -## 1.3.0 (January 16th, 2007) ## - -* Make mime version default to 1.0. closes #2323 *ror@andreas-s.net* - -* Make sure quoted-printable text is decoded correctly when only portions of the text are encoded. closes #3154. *jon@siliconcircus.com* - -* Make sure DOS newlines in quoted-printable text are normalized to unix newlines before unquoting. closes #4166 and #4452. *Jamis Buck* - -* Fixed that iconv decoding should catch InvalidEncoding #3153 *jon@siliconcircus.com* - -* Tighten rescue clauses. #5985 *james@grayproductions.net* - -* Automatically included ActionController::UrlWriter, such that URL generation can happen within ActionMailer controllers. *David Heinemeier Hansson* - -* Replace Reloadable with Reloadable::Deprecated. *Nicholas Seckar* - -* Mailer template root applies to a class and its subclasses rather than acting globally. #5555 *somekool@gmail.com* - -* Resolve action naming collision. #5520 *ssinghi@kreeti.com* - -* ActionMailer::Base documentation rewrite. Closes #4991 *Kevin Clark, Marcel Molina Jr.* - -* Replace alias method chaining with Module#alias_method_chain. *Marcel Molina Jr.* - -* Replace Ruby's deprecated append_features in favor of included. *Marcel Molina Jr.* - -* Correct spurious documentation example code which results in a SyntaxError. *Marcel Molina Jr.* - - -## 1.2.1 (April 6th, 2006) ## - -* Be part of Rails 1.1.1 - - -## 1.2.0 (March 27th, 2006) ## - -* Nil charset caused subject line to be improperly quoted in implicitly multipart messages #2662 *ehalvorsen+rails@runbox.com* - -* Parse content-type apart before using it so that sub-parts of the header can be set correctly #2918 *Jamis Buck* - -* Make custom headers work in subparts #4034 *elan@bluemandrill.com* - -* Template paths with dot chars in them no longer mess up implicit template selection for multipart messages #3332 *Chad Fowler* - -* Make sure anything with content-disposition of "attachment" is passed to the attachment presenter when parsing an email body *Jamis Buck* - -* Make sure TMail#attachments includes anything with content-disposition of "attachment", regardless of content-type *Jamis Buck* - - -## 1.1.5 (December 13th, 2005) ## - -* Become part of Rails 1.0 - - -## 1.1.4 (December 7th, 2005) ## - -* Rename Version constant to VERSION. #2802 *Marcel Molina Jr.* - -* Stricter matching for implicitly multipart filenames excludes files ending in unsupported extensions (such as foo.rhtml.bak) and without a two-part content type (such as foo.text.rhtml or foo.text.really.plain.rhtml). #2398 *Dave Burt , Jeremy Kemper* - - -## 1.1.3 (November 7th, 2005) ## - -* Allow Mailers to have custom initialize methods that set default instance variables for all mail actions #2563 *mrj@bigpond.net.au* - - -## 1.1.2 (October 26th, 2005) ## - -* Upgraded to Action Pack 1.10.2 - - -## 1.1.1 (October 19th, 2005) ## - -* Upgraded to Action Pack 1.10.1 - - -## 1.1.0 (October 16th, 2005) ## - -* Update and extend documentation (rdoc) - -* Minero Aoki made TMail available to Rails/ActionMailer under the MIT license (instead of LGPL) *RubyConf '05* - -* Austin Ziegler made Text::Simple available to Rails/ActionMailer under a MIT-like licens *See rails ML, subject "Text::Format Licence Exception" on Oct 15, 2005* - -* Fix vendor require paths to prevent files being required twice - -* Don't add charset to content-type header for a part that contains subparts (for AOL compatibility) #2013 *John Long* - -* Preserve underscores when unquoting message bodies #1930 - -* Encode multibyte characters correctly #1894 - -* Multipart messages specify a MIME-Version header automatically #2003 *John Long* - -* Add a unified render method to ActionMailer (delegates to ActionView::Base#render) - -* Move mailer initialization to a separate (overridable) method, so that subclasses may alter the various defaults #1727 - -* Look at content-location header (if available) to determine filename of attachments #1670 - -* ActionMailer::Base.deliver(email) had been accidentally removed, but was documented in the Rails book #1849 - -* Fix problem with sendmail delivery where headers should be delimited by \n characters instead of \r\n, which confuses some mail readers #1742 *Kent Sibilev* - - -## 1.0.1 (11 July, 2005) ## - -* Bind to Action Pack 1.9.1 - - -## 1.0.0 (6 July, 2005) ## - -* Avoid adding nil header values #1392 - -* Better multipart support with implicit multipart/alternative and sorting of subparts *John Long* - -* Allow for nested parts in multipart mails #1570 *Flurin Egger* - -* Normalize line endings in outgoing mail bodies to "\n" #1536 *John Long* - -* Allow template to be explicitly specified #1448 *tuxie@dekadance.se* - -* Allow specific "multipart/xxx" content-type to be set on multipart messages #1412 *Flurin Egger* - -* Unquoted @ characters in headers are now accepted in spite of RFC 822 #1206 - -* Helper support (borrowed from ActionPack) - -* Silently ignore Errno::EINVAL errors when converting text. - -* Don't cause an error when parsing an encoded attachment name #1340 *lon@speedymac.com* - -* Nested multipart message parts are correctly processed in TMail::Mail#body - -* BCC headers are removed when sending via SMTP #1402 - -* Added 'content_type' accessor, to allow content type to be set on a per-message basis. content_type defaults to "text/plain". - -* Silently ignore Iconv::IllegalSequence errors when converting text #1341 *lon@speedymac.com* - -* Support attachments and multipart messages. - -* Added new accessors for the various mail properties. - -* Fix to only perform the charset conversion if a 'from' and a 'to' charset are given (make no assumptions about what the charset was) #1276 *Jamis Buck* - -* Fix attachments and content-type problems #1276 *Jamis Buck* - -* Fixed the TMail#body method to look at the content-transfer-encoding header and unquote the body according to the rules it specifies #1265 *Jamis Buck* - -* Added unquoting even if the iconv lib can't be loaded--in that case, only the charset conversion is skipped #1265 *Jamis Buck* - -* Added automatic decoding of base64 bodies #1214 *Jamis Buck* - -* Added that delivery errors are caught in a way so the mail is still returned whether the delivery was successful or not - -* Fixed that email address like "Jamis Buck, M.D." would cause the quoter to generate emails resulting in "bad address" errors from the mail server #1220 *Jamis Buck* - - -## 0.9.1 (20th April, 2005) ## - -* Depend on Action Pack 1.8.1 - - -## 0.9.0 (19th April, 2005) ## - -* Added that deliver_* will now return the email that was sent - -* Added that quoting to UTF-8 only happens if the characters used are in that range #955 *Jamis Buck* - -* Fixed quoting for all address headers, not just to #955 *Jamis Buck* - -* Fixed unquoting of emails that doesn't have an explicit charset #1036 *wolfgang@stufenlos.net* - - -## 0.8.1 (27th March, 2005) ## - -* Fixed that if charset was found that the end of a mime part declaration TMail would throw an error #919 *lon@speedymac.com* - -* Fixed that TMail::Unquoter would fail to recognize quoting method if it was in lowercase #919 *lon@speedymac.com* - -* Fixed that TMail::Encoder would fail when it attempts to parse e-mail addresses which are encoded using something other than the messages encoding method #919 *lon@speedymac.com* - -* Added rescue for missing iconv library and throws warnings if subject/body is called on a TMail object without it instead - - -## 0.8.0 (22th March, 2005) ## - -* Added framework support for processing incoming emails with an Action Mailer class. See example in README. - - -## 0.7.1 (7th March, 2005) ## - -* Bind to newest Action Pack (1.5.1) - - -## 0.7.0 (24th February, 2005) ## - -* Added support for charsets for both subject and body. The default charset is now UTF-8 #673 [Jamis Buck]. Examples: - - def iso_charset(recipient) - @recipients = recipient - @subject = "testing iso charsets" - @from = "system@loudthinking.com" - @body = "Nothing to see here." - @charset = "iso-8859-1" - end - - def unencoded_subject(recipient) - @recipients = recipient - @subject = "testing unencoded subject" - @from = "system@loudthinking.com" - @body = "Nothing to see here." - @encode_subject = false - @charset = "iso-8859-1" - end - - -## 0.6.1 (January 18th, 2005) ## - -* Fixed sending of emails to use Tmail#from not the deprecated Tmail#from_address - - -## 0.6 (January 17th, 2005) ## - -* Fixed that bcc and cc should be settable through @bcc and @cc -- not just @headers["Bcc"] and @headers["Cc"] #453 *Eric Hodel* - -* Fixed Action Mailer to be "warnings safe" so you can run with ruby -w and not get framework warnings #453 *Eric Hodel* - - -## 0.5 ## - -* Added access to custom headers, like cc, bcc, and reply-to #268 [Andreas Schwarz]. Example: - - def post_notification(recipients, post) - @recipients = recipients - @from = post.author.email_address_with_name - @headers["bcc"] = SYSTEM_ADMINISTRATOR_EMAIL - @headers["reply-to"] = "notifications@example.com" - @subject = "[#{post.account.name} #{post.title}]" - @body["post"] = post - end - -## 0.4 (5) ## - -* Consolidated the server configuration options into Base#server_settings= and expanded that with controls for authentication and more *Marten* - NOTE: This is an API change that could potentially break your application if you used the old application form. Please do change! - -* Added Base#deliveries as an accessor for an array of emails sent out through that ActionMailer class when using the :test delivery option. *Jeremy Kemper* - -* Added Base#perform_deliveries= which can be set to false to turn off the actual delivery of the email through smtp or sendmail. - This is especially useful for functional testing that shouldn't send off real emails, but still trigger delivery_* methods. - -* Added option to specify delivery method with Base#delivery_method=. Default is :smtp and :sendmail is currently the only other option. - Sendmail is assumed to be present at "/usr/sbin/sendmail" if that option is used. *Kent Sibilev* - -* Dropped "include TMail" as it added to much baggage into the default namespace (like Version) *Chad Fowler* - - -## 0.3 ## - -* First release +Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actionmailer/CHANGELOG.md) for previous changes. diff --git a/actionmailer/MIT-LICENSE b/actionmailer/MIT-LICENSE index 810daf856c435..7be9ac633faf0 100644 --- a/actionmailer/MIT-LICENSE +++ b/actionmailer/MIT-LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2004-2012 David Heinemeier Hansson +Copyright (c) David Heinemeier Hansson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/actionmailer/README.rdoc b/actionmailer/README.rdoc index 755717cfba945..d26365048b4f7 100644 --- a/actionmailer/README.rdoc +++ b/actionmailer/README.rdoc @@ -1,6 +1,6 @@ = Action Mailer -- Easy email delivery and testing -Action Mailer is a framework for designing email-service layers. These layers +Action Mailer is a framework for designing email service layers. These layers are used to consolidate code for sending out forgotten passwords, welcome wishes on signup, invoices for billing, and any other use case that requires a written notification to either a person or another system. @@ -13,6 +13,8 @@ Additionally, an Action Mailer class can be used to process incoming email, such as allowing a blog to accept new posts from an email (which could even have been sent from a phone). +You can read more about Action Mailer in the {Action Mailer Basics}[https://guides.rubyonrails.org/action_mailer_basics.html] guide. + == Sending emails The framework works by initializing any instance variables you want to be @@ -22,12 +24,12 @@ the email. This can be as simple as: class Notifier < ActionMailer::Base - delivers_from 'system@loudthinking.com' + default from: 'system@loudthinking.com' def welcome(recipient) @recipient = recipient - mail(:to => recipient, - :subject => "[Signed up] Welcome #{recipient}") + mail(to: recipient, + subject: "[Signed up] Welcome #{recipient}") end end @@ -61,77 +63,48 @@ generated would look like this: Thank you for signing up! -In previous version of Rails you would call create_method_name and -deliver_method_name. Rails 3.0 has a much simpler interface - you -simply call the method and optionally call +deliver+ on the return value. +In order to send mails, you simply call the method and then call +deliver_now+ on the return value. Calling the method returns a Mail Message object: - message = Notifier.welcome # => Returns a Mail::Message object - message.deliver # => delivers the email + message = Notifier.welcome("david@loudthinking.com") # => Returns a Mail::Message object + message.deliver_now # => delivers the email Or you can just chain the methods together like: - Notifier.welcome.deliver # Creates the email and sends it immediately + Notifier.welcome("david@loudthinking.com").deliver_now # Creates the email and sends it immediately == Setting defaults -It is possible to set default values that will be used in every method in your Action Mailer class. To implement this functionality, you just call the public class method default which you get for free from ActionMailer::Base. This method accepts a Hash as the parameter. You can use any of the headers e-mail messages has, like :from as the key. You can also pass in a string as the key, like "Content-Type", but Action Mailer does this out of the box for you, so you won't need to worry about that. Finally, it is also possible to pass in a Proc that will get evaluated when it is needed. +It is possible to set default values that will be used in every method in your +Action Mailer class. To implement this functionality, you just call the public +class method +default+ which you get for free from ActionMailer::Base. +This method accepts a Hash as the parameter. You can use any of the headers, +email messages have, like +:from+ as the key. You can also pass in a string as +the key, like "Content-Type", but Action Mailer does this out of the box for you, +so you won't need to worry about that. Finally, it is also possible to pass in a +Proc that will get evaluated when it is needed. -Note that every value you set with this method will get over written if you use the same key in your mailer method. +Note that every value you set with this method will get overwritten if you use the +same key in your mailer method. Example: class AuthenticationMailer < ActionMailer::Base - default :from => "awesome@application.com", :subject => Proc.new { "E-mail was generated at #{Time.now}" } + default from: "awesome@application.com", subject: Proc.new { "E-mail was generated at #{Time.now}" } ..... end -== Receiving emails - -To receive emails, you need to implement a public instance method called receive that takes an -email object as its single parameter. The Action Mailer framework has a corresponding class method, -which is also called receive, that accepts a raw, unprocessed email as a string, which it then turns -into the email object and calls the receive instance method. - -Example: - - class Mailman < ActionMailer::Base - def receive(email) - page = Page.find_by_address(email.to.first) - page.emails.create( - :subject => email.subject, :body => email.body - ) - - if email.has_attachments? - email.attachments.each do |attachment| - page.attachments.create({ - :file => attachment, :description => email.subject - }) - end - end - end - end - -This Mailman can be the target for Postfix or other MTAs. In Rails, you would use the runner in the -trivial case like this: - - rails runner 'Mailman.receive(STDIN.read)' - -However, invoking Rails in the runner for each mail to be received is very resource intensive. A single -instance of Rails should be run within a daemon, if it is going to be utilized to process more than just -a limited number of email. - == Configuration The Base class has the full list of configuration options. Here's an example: ActionMailer::Base.smtp_settings = { - :address => 'smtp.yourserver.com', # default: localhost - :port => '25', # default: 25 - :user_name => 'user', - :password => 'pass', - :authentication => :plain # :plain, :login or :cram_md5 + address: 'smtp.yourserver.com', # default: localhost + port: '25', # default: 25 + user_name: 'user', + password: 'pass', + authentication: :plain # :plain, :login or :cram_md5 } @@ -139,27 +112,30 @@ The Base class has the full list of configuration options. Here's an example: The latest version of Action Mailer can be installed with RubyGems: - % [sudo] gem install actionmailer + $ gem install actionmailer -Source code can be downloaded as part of the Rails project on GitHub +Source code can be downloaded as part of the \Rails project on GitHub: -* https://github.com/rails/rails/tree/master/actionmailer +* https://github.com/rails/rails/tree/main/actionmailer == License Action Mailer is released under the MIT license: -* http://www.opensource.org/licenses/MIT +* https://opensource.org/licenses/MIT == Support API documentation is at -* http://api.rubyonrails.org +* https://api.rubyonrails.org -Bug reports and feature requests can be filed with the rest for the Ruby on Rails project here: +Bug reports for the Ruby on \Rails project can be filed here: * https://github.com/rails/rails/issues +Feature requests should be discussed on the rails-core mailing list here: + +* https://discuss.rubyonrails.org/c/rubyonrails-core diff --git a/actionmailer/Rakefile b/actionmailer/Rakefile old mode 100755 new mode 100644 index 8f5aeb960350f..8c6181e659337 --- a/actionmailer/Rakefile +++ b/actionmailer/Rakefile @@ -1,37 +1,26 @@ -#!/usr/bin/env rake -require 'rake/testtask' -require 'rake/packagetask' -require 'rubygems/package_task' +# frozen_string_literal: true + +require "rake/testtask" desc "Default Task" -task :default => [ :test ] +task default: [ :test ] + +ENV["RAILS_MINITEST_PLUGIN"] = "true" # Run the unit tests Rake::TestTask.new { |t| t.libs << "test" - t.pattern = 'test/**/*_test.rb' + t.pattern = "test/**/*_test.rb" t.warning = true t.verbose = true + t.options = "--profile" if ENV["CI"] + t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION) } namespace :test do task :isolated do - ruby = File.join(*RbConfig::CONFIG.values_at('bindir', 'RUBY_INSTALL_NAME')) Dir.glob("test/**/*_test.rb").all? do |file| - sh(ruby, '-Ilib:test', file) - end or raise "Failures" + sh(Gem.ruby, "-w", "-Ilib:test", file) + end || raise("Failures") end end - -spec = eval(File.read('actionmailer.gemspec')) - -Gem::PackageTask.new(spec) do |p| - p.gem_spec = spec -end - -desc "Release to gemcutter" -task :release => :package do - require 'rake/gemcutter' - Rake::Gemcutter::Tasks.new(spec).define - Rake::Task['gem:push'].invoke -end diff --git a/actionmailer/actionmailer.gemspec b/actionmailer/actionmailer.gemspec index c605f1ff04df5..88a9be08c1cf7 100644 --- a/actionmailer/actionmailer.gemspec +++ b/actionmailer/actionmailer.gemspec @@ -1,21 +1,43 @@ -version = File.read(File.expand_path("../../RAILS_VERSION", __FILE__)).strip +# frozen_string_literal: true + +version = File.read(File.expand_path("../RAILS_VERSION", __dir__)).strip Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY - s.name = 'actionmailer' + s.name = "actionmailer" s.version = version - s.summary = 'Email composition, delivery, and receiving framework (part of Rails).' - s.description = 'Email on Rails. Compose, deliver, receive, and test emails using the familiar controller/view pattern. First-class support for multipart email and attachments.' - s.required_ruby_version = '>= 1.9.3' + s.summary = "Email composition and delivery framework (part of Rails)." + s.description = "Email on Rails. Compose, deliver, and test emails using the familiar controller/view pattern. First-class support for multipart email and attachments." + + s.required_ruby_version = ">= 3.2.0" + + s.license = "MIT" + + s.author = "David Heinemeier Hansson" + s.email = "david@loudthinking.com" + s.homepage = "https://rubyonrails.org/" + + s.files = Dir["CHANGELOG.md", "README.rdoc", "MIT-LICENSE", "lib/**/*"] + s.require_path = "lib" + s.requirements << "none" + + s.metadata = { + "bug_tracker_uri" => "https://github.com/rails/rails/issues", + "changelog_uri" => "https://github.com/rails/rails/blob/v#{version}/actionmailer/CHANGELOG.md", + "documentation_uri" => "https://api.rubyonrails.org/v#{version}/", + "mailing_list_uri" => "https://discuss.rubyonrails.org/c/rubyonrails-talk", + "source_code_uri" => "https://github.com/rails/rails/tree/v#{version}/actionmailer", + "rubygems_mfa_required" => "true", + } - s.author = 'David Heinemeier Hansson' - s.email = 'david@loudthinking.com' - s.homepage = 'http://www.rubyonrails.org/' + # NOTE: Please read our dependency guidelines before updating versions: + # https://edgeguides.rubyonrails.org/security.html#dependency-management-and-cves - s.files = Dir['CHANGELOG.md', 'README.rdoc', 'MIT-LICENSE', 'lib/**/*'] - s.require_path = 'lib' - s.requirements << 'none' + s.add_dependency "activesupport", version + s.add_dependency "actionpack", version + s.add_dependency "actionview", version + s.add_dependency "activejob", version - s.add_dependency('actionpack', version) - s.add_dependency('mail', '~> 2.4.1') + s.add_dependency "mail", ">= 2.8.0" + s.add_dependency "rails-dom-testing", "~> 2.2" end diff --git a/actionmailer/bin/test b/actionmailer/bin/test new file mode 100755 index 0000000000000..c53377cc970f4 --- /dev/null +++ b/actionmailer/bin/test @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +COMPONENT_ROOT = File.expand_path("..", __dir__) +require_relative "../../tools/test" diff --git a/actionmailer/lib/action_mailer.rb b/actionmailer/lib/action_mailer.rb index 1045dd58ef1d5..f58a858094143 100644 --- a/actionmailer/lib/action_mailer.rb +++ b/actionmailer/lib/action_mailer.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + #-- -# Copyright (c) 2004-2012 David Heinemeier Hansson +# Copyright (c) David Heinemeier Hansson # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -21,28 +23,57 @@ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. #++ -actionpack_path = File.expand_path('../../../actionpack/lib', __FILE__) -$:.unshift(actionpack_path) if File.directory?(actionpack_path) && !$:.include?(actionpack_path) - -require 'abstract_controller' -require 'action_view' -require 'action_mailer/version' +require "abstract_controller" +require "action_mailer/version" +require "action_mailer/deprecator" # Common Active Support usage in Action Mailer -require 'active_support/core_ext/class' -require 'active_support/core_ext/object/blank' -require 'active_support/core_ext/module/attr_internal' -require 'active_support/core_ext/module/delegation' -require 'active_support/core_ext/string/inflections' -require 'active_support/lazy_load_hooks' +require "active_support" +require "active_support/rails" +require "active_support/core_ext/class" +require "active_support/core_ext/module/attr_internal" +require "active_support/core_ext/string/inflections" +require "active_support/lazy_load_hooks" +# :include: ../README.rdoc module ActionMailer extend ::ActiveSupport::Autoload - autoload :Collector + eager_autoload do + autoload :Collector + end + autoload :Base + autoload :Callbacks autoload :DeliveryMethods + autoload :InlinePreviewInterceptor autoload :MailHelper + autoload :Parameterized + autoload :Preview + autoload :Previews, "action_mailer/preview" autoload :TestCase autoload :TestHelper + autoload :MessageDelivery + autoload :MailDeliveryJob + autoload :QueuedDelivery + autoload :FormBuilder + + def self.eager_load! + super + + require "mail" + Mail.eager_autoload! + + Base.descendants.each do |mailer| + mailer.eager_load! unless mailer.abstract? + end + end +end + +autoload :Mime, "action_dispatch/http/mime_type" + +ActiveSupport.on_load(:action_view) do + ActionView::Base.default_formats ||= Mime::SET.symbols + ActionView::Template.mime_types_implementation = Mime + ActionView::LookupContext::DetailsKey.clear end diff --git a/actionmailer/lib/action_mailer/base.rb b/actionmailer/lib/action_mailer/base.rb index 1800ff5839d8f..bc3e8f306ba52 100644 --- a/actionmailer/lib/action_mailer/base.rb +++ b/actionmailer/lib/action_mailer/base.rb @@ -1,33 +1,43 @@ -require 'mail' -require 'action_mailer/collector' -require 'active_support/core_ext/object/blank' -require 'active_support/core_ext/string/inflections' -require 'active_support/core_ext/hash/except' -require 'action_mailer/log_subscriber' - -module ActionMailer #:nodoc: +# frozen_string_literal: true + +require "mail" +require "action_mailer/collector" +require "active_support/core_ext/string/inflections" +require "active_support/core_ext/hash/except" +require "active_support/core_ext/module/anonymous" + +require "action_mailer/log_subscriber" +require "action_mailer/rescuable" + +module ActionMailer + # = Action Mailer \Base + # # Action Mailer allows you to send email from your application using a mailer model and views. # - # = Mailer Models + # == Mailer Models # # To use Action Mailer, you need to create a mailer model. # - # $ rails generate mailer Notifier + # $ bin/rails generate mailer Notifier # - # The generated model inherits from ActionMailer::Base. Emails are defined by creating methods - # within the model which are then used to set variables to be used in the mail template, to - # change options on the mail, or to add attachments. + # The generated model inherits from ApplicationMailer which in turn + # inherits from +ActionMailer::Base+. A mailer model defines methods + # used to generate an email message. In these methods, you can set up variables to be used in + # the mailer views, options on the mail itself such as the :from address, and attachments. # - # Examples: + # class ApplicationMailer < ActionMailer::Base + # default from: 'from@example.com' + # layout 'mailer' + # end # - # class Notifier < ActionMailer::Base - # default :from => 'no-reply@example.com', - # :return_path => 'system@example.com' + # class NotifierMailer < ApplicationMailer + # default from: 'no-reply@example.com', + # return_path: 'system@example.com' # # def welcome(recipient) # @account = recipient - # mail(:to => recipient.email_address_with_name, - # :bcc => ["bcc@example.com", "Order Watcher "]) + # mail(to: recipient.email_address_with_name, + # bcc: ["bcc@example.com", "Order Watcher "]) # end # end # @@ -40,73 +50,71 @@ module ActionMailer #:nodoc: # in the same manner as attachments[]= # # * headers[]= - Allows you to specify any header field in your email such - # as headers['X-No-Spam'] = 'True'. Note, while most fields like To: - # From: can only appear once in an email header, other fields like X-Anything - # can appear multiple times. If you want to change a field that can appear multiple times, - # you need to set it to nil first so that Mail knows you are replacing it and not adding - # another field of the same name. + # as headers['X-No-Spam'] = 'True'. Note that declaring a header multiple times + # will add many fields of the same name. Read #headers doc for more information. # # * headers(hash) - Allows you to specify multiple headers in your email such # as headers({'X-No-Spam' => 'True', 'In-Reply-To' => '1234@message.id'}) # # * mail - Allows you to specify email to be sent. # - # The hash passed to the mail method allows you to specify any header that a Mail::Message - # will accept (any valid Email header including optional fields). + # The hash passed to the mail method allows you to specify any header that a +Mail::Message+ + # will accept (any valid email header including optional fields). # - # The mail method, if not passed a block, will inspect your views and send all the views with + # The +mail+ method, if not passed a block, will inspect your views and send all the views with # the same name as the method, so the above action would send the +welcome.text.erb+ view - # file as well as the +welcome.text.html.erb+ view file in a +multipart/alternative+ email. + # file as well as the +welcome.html.erb+ view file in a +multipart/alternative+ email. # # If you want to explicitly render only certain templates, pass a block: # - # mail(:to => user.email) do |format| + # mail(to: user.email) do |format| # format.text # format.html # end # # The block syntax is also useful in providing information specific to a part: # - # mail(:to => user.email) do |format| - # format.text(:content_transfer_encoding => "base64") + # mail(to: user.email) do |format| + # format.text(content_transfer_encoding: "base64") # format.html # end # # Or even to render a special view: # - # mail(:to => user.email) do |format| + # mail(to: user.email) do |format| # format.text # format.html { render "some_other_template" } # end # - # = Mailer views + # == Mailer views # # Like Action Controller, each mailer class has a corresponding view directory in which each # method of the class looks for a template with its name. # - # To define a template to be used with a mailing, create an .erb file with the same + # To define a template to be used with a mailer, create an .erb file with the same # name as the method in your mailer model. For example, in the mailer defined above, the template at - # app/views/notifier/welcome.text.erb would be used to generate the email. + # app/views/notifier_mailer/welcome.text.erb would be used to generate the email. # - # Variables defined in the model are accessible as instance variables in the view. + # Variables defined in the methods of your mailer model are accessible as instance variables in their + # corresponding view. # # Emails by default are sent in plain text, so a sample view for our model example might look like this: # # Hi <%= @account.name %>, # Thanks for joining our service! Please check back often. # - # You can even use Action Pack helpers in these views. For example: + # You can even use Action View helpers in these views. For example: # # You got a new note! - # <%= truncate(@note.body, :length => 25) %> + # <%= truncate(@note.body, length: 25) %> # - # If you need to access the subject, from or the recipients in the view, you can do that through message object: + # If you need to access the subject, from, or the recipients in the view, you can do that through message object: # # You got a new note from <%= message.from %>! - # <%= truncate(@note.body, :length => 25) %> + # <%= truncate(@note.body, length: 25) %> # # - # = Generating URLs + # == Generating URLs # # URLs can be generated in mailer views using url_for or named routes. Unlike controllers from # Action Pack, the mailer instance doesn't have any context about the incoming request, so you'll need @@ -114,11 +122,11 @@ module ActionMailer #:nodoc: # # When using url_for you'll need to provide the :host, :controller, and :action: # - # <%= url_for(:host => "example.com", :controller => "welcome", :action => "greeting") %> + # <%= url_for(host: "example.com", controller: "welcome", action: "greeting") %> # # When using named routes you only need to supply the :host: # - # <%= users_url(http://github.com/:host%20=%3E%20%22example.com") %> + # <%= users_url(/service/host: "example.com") %> # # You should use the named_route_url style (which generates absolute URLs) and avoid using the # named_route_path style (which generates relative URLs), since clients reading the mail will @@ -127,35 +135,49 @@ module ActionMailer #:nodoc: # It is also possible to set a default host that will be used in all mailers by setting the :host # option as a configuration option in config/application.rb: # - # config.action_mailer.default_url_options = { :host => "example.com" } + # config.action_mailer.default_url_options = { host: "example.com" } + # + # You can also define a default_url_options method on individual mailers to override these + # default settings per-mailer. + # + # By default when config.force_ssl is +true+, URLs generated for hosts will use the HTTPS protocol. + # + # == Sending mail + # + # Once a mailer action and template are defined, you can deliver your message or defer its creation and + # delivery for later: + # + # NotifierMailer.welcome(User.first).deliver_now # sends the email + # mail = NotifierMailer.welcome(User.first) # => an ActionMailer::MessageDelivery object + # mail.deliver_now # generates and sends the email now # - # When you decide to set a default :host for your mailers, then you need to make sure to use the - # :only_path => false option when using url_for. Since the url_for view helper - # will generate relative URLs by default when a :host option isn't explicitly provided, passing - # :only_path => false will ensure that absolute URLs are generated. + # The ActionMailer::MessageDelivery class is a wrapper around a delegate that will call + # your method to generate the mail. If you want direct access to the delegator, or +Mail::Message+, + # you can call the message method on the ActionMailer::MessageDelivery object. # - # = Sending mail + # NotifierMailer.welcome(User.first).message # => a Mail::Message object # - # Once a mailer action and template are defined, you can deliver your message or create it and save it - # for delivery later: + # Action Mailer is nicely integrated with Active Job so you can generate and send emails in the background + # (example: outside of the request-response cycle, so the user doesn't have to wait on it): # - # Notifier.welcome(david).deliver # sends the email - # mail = Notifier.welcome(david) # => a Mail::Message object - # mail.deliver # sends the email + # NotifierMailer.welcome(User.first).deliver_later # enqueue the email sending to Active Job + # + # Note that deliver_later will execute your method from the background job. # # You never instantiate your mailer class. Rather, you just call the method you defined on the class itself. + # All instance methods are expected to return a message object to be sent. # - # = Multipart Emails + # == Multipart Emails # # Multipart messages can also be used implicitly because Action Mailer will automatically detect and use # multipart templates, where each template is named after the name of the action, followed by the content - # type. Each such detected template will be added as a separate part to the message. + # type. Each such detected template will be added to the message, as a separate part. # # For example, if the following templates exist: # * signup_notification.text.erb - # * signup_notification.text.html.erb - # * signup_notification.text.xml.builder - # * signup_notification.text.yaml.erb + # * signup_notification.html.erb + # * signup_notification.xml.builder + # * signup_notification.yml.erb # # Each would be rendered and added as a separate part to the message, with the corresponding content # type. The content type for the entire message is automatically set to multipart/alternative, @@ -166,32 +188,55 @@ module ActionMailer #:nodoc: # This means that you'll have to manually add each part to the email and set the content type of the email # to multipart/alternative. # - # = Attachments + # == Attachments # # Sending attachment in emails is easy: # - # class ApplicationMailer < ActionMailer::Base + # class NotifierMailer < ApplicationMailer # def welcome(recipient) # attachments['free_book.pdf'] = File.read('path/to/file.pdf') - # mail(:to => recipient, :subject => "New account information") + # mail(to: recipient, subject: "New account information") # end # end # - # Which will (if it had both a welcome.text.erb and welcome.text.html.erb + # Which will (if it had both a welcome.text.erb and welcome.html.erb # template in the view directory), send a complete multipart/mixed email with two parts, # the first part being a multipart/alternative with the text and HTML email parts inside, # and the second being a application/pdf with a Base64 encoded copy of the file.pdf book # with the filename +free_book.pdf+. # - # = Inline Attachments + # If you need to send attachments with no content, you need to create an empty view for it, + # or add an empty body parameter like this: + # + # class NotifierMailer < ApplicationMailer + # def welcome(recipient) + # attachments['free_book.pdf'] = File.read('path/to/file.pdf') + # mail(to: recipient, subject: "New account information", body: "") + # end + # end + # + # You can also send attachments with HTML template, in this case you need to add body, attachments, + # and custom content type like this: + # + # class NotifierMailer < ApplicationMailer + # def welcome(recipient) + # attachments["free_book.pdf"] = File.read("path/to/file.pdf") + # mail(to: recipient, + # subject: "New account information", + # content_type: "text/html", + # body: "Hello there") + # end + # end + # + # == Inline Attachments # # You can also specify that a file should be displayed inline with other HTML. This is useful # if you want to display a corporate logo or a photo. # - # class ApplicationMailer < ActionMailer::Base + # class NotifierMailer < ApplicationMailer # def welcome(recipient) # attachments.inline['photo.png'] = File.read('path/to/photo.png') - # mail(:to => recipient, :subject => "Here is what we look like") + # mail(to: recipient, subject: "Here is what we look like") # end # end # @@ -207,9 +252,9 @@ module ActionMailer #:nodoc: # #

Please Don't Cringe

# - # <%= image_tag attachments['photo.png'].url, :alt => 'Our Photo', :class => 'photo' -%> + # <%= image_tag attachments['photo.png'].url, alt: 'Our Photo', class: 'photo' -%> # - # = Observing and Intercepting Mails + # == Observing and Intercepting Mails # # Action Mailer provides hooks into the Mail observer and interceptor methods. These allow you to # register classes that are called during the mail delivery life cycle. @@ -220,63 +265,161 @@ module ActionMailer #:nodoc: # An interceptor class must implement the :delivering_email(message) method which will be # called before the email is sent, allowing you to make modifications to the email before it hits # the delivery agents. Your class should make any needed modifications directly to the passed - # in Mail::Message instance. + # in +Mail::Message+ instance. # - # = Default Hash + # == Default \Hash # # Action Mailer provides some intelligent defaults for your emails, these are usually specified in a # default method inside the class definition: # - # class Notifier < ActionMailer::Base - # default :sender => 'system@example.com' + # class NotifierMailer < ApplicationMailer + # default sender: 'system@example.com' # end # - # You can pass in any header value that a Mail::Message accepts. Out of the box, - # ActionMailer::Base sets the following: + # You can pass in any header value that a +Mail::Message+ accepts. Out of the box, + # +ActionMailer::Base+ sets the following: # - # * :mime_version => "1.0" - # * :charset => "UTF-8", - # * :content_type => "text/plain", - # * :parts_order => [ "text/plain", "text/enriched", "text/html" ] + # * mime_version: "1.0" + # * charset: "UTF-8" + # * content_type: "text/plain" + # * parts_order: [ "text/plain", "text/enriched", "text/html" ] # - # parts_order and charset are not actually valid Mail::Message header fields, + # parts_order and charset are not actually valid +Mail::Message+ header fields, # but Action Mailer translates them appropriately and sets the correct values. # # As you can pass in any header, you need to either quote the header as a string, or pass it in as # an underscored symbol, so the following will work: # - # class Notifier < ActionMailer::Base + # class NotifierMailer < ApplicationMailer # default 'Content-Transfer-Encoding' => '7bit', - # :content_description => 'This is a description' + # content_description: 'This is a description' # end # - # Finally, Action Mailer also supports passing Proc objects into the default hash, so you - # can define methods that evaluate as the message is being generated: + # Finally, Action Mailer also supports passing Proc and Lambda objects into the default hash, + # so you can define methods that evaluate as the message is being generated: # - # class Notifier < ActionMailer::Base - # default 'X-Special-Header' => Proc.new { my_method } + # class NotifierMailer < ApplicationMailer + # default 'X-Special-Header' => Proc.new { my_method }, to: -> { @inviter.email_address } # # private - # # def my_method # 'some complex call' # end # end # - # Note that the proc is evaluated right at the start of the mail message generation, so if you - # set something in the defaults using a proc, and then set the same thing inside of your - # mailer method, it will get over written by the mailer method. + # Note that the proc/lambda is evaluated right at the start of the mail message generation, so if you + # set something in the default hash using a proc, and then set the same thing inside of your + # mailer method, it will get overwritten by the mailer method. + # + # It is also possible to set these default options that will be used in all mailers through + # the default_options= configuration in config/application.rb: + # + # config.action_mailer.default_options = { from: "no-reply@example.org" } + # + # == \Callbacks + # + # You can specify callbacks using before_action and after_action for configuring your messages, + # and using before_deliver and after_deliver for wrapping the delivery process. + # For example, when you want to add default inline attachments and log delivery for all messages + # sent out by a certain mailer class: + # + # class NotifierMailer < ApplicationMailer + # before_action :add_inline_attachment! + # after_deliver :log_delivery + # + # def welcome + # mail + # end + # + # private + # def add_inline_attachment! + # attachments.inline["footer.jpg"] = File.read('/path/to/filename.jpg') + # end + # + # def log_delivery + # Rails.logger.info "Sent email with message id '#{message.message_id}' at #{Time.current}." + # end + # end + # + # Action callbacks in Action Mailer are implemented using + # AbstractController::Callbacks, so you can define and configure + # callbacks in the same manner that you would use callbacks in classes that + # inherit from ActionController::Base. + # + # Note that unless you have a specific reason to do so, you should prefer + # using before_action rather than after_action in your + # Action Mailer classes so that headers are parsed properly. + # + # == Rescuing Errors # - # = Configuration options + # +rescue+ blocks inside of a mailer method cannot rescue errors that occur + # outside of rendering -- for example, record deserialization errors in a + # background job, or errors from a third-party mail delivery service. + # + # To rescue errors that occur during any part of the mailing process, use + # {rescue_from}[rdoc-ref:ActiveSupport::Rescuable::ClassMethods#rescue_from]: + # + # class NotifierMailer < ApplicationMailer + # rescue_from ActiveJob::DeserializationError do + # # ... + # end + # + # rescue_from "SomeThirdPartyService::ApiError" do + # # ... + # end + # + # def notify(recipient) + # mail(to: recipient, subject: "Notification") + # end + # end + # + # == Previewing emails + # + # You can preview your email templates visually by adding a mailer preview file to the + # ActionMailer::Base.preview_paths. Since most emails do something interesting + # with database data, you'll need to write some scenarios to load messages with fake data: + # + # class NotifierMailerPreview < ActionMailer::Preview + # def welcome + # NotifierMailer.welcome(User.first) + # end + # end + # + # Methods must return a +Mail::Message+ object which can be generated by calling the mailer + # method without the additional deliver_now / deliver_later. The location of the + # mailer preview directories can be configured using the preview_paths option which has a default + # of test/mailers/previews: + # + # config.action_mailer.preview_paths << "#{Rails.root}/lib/mailer_previews" + # + # An overview of all previews is accessible at http://localhost:3000/rails/mailers + # on a running development server instance. + # + # Previews can also be intercepted in a similar manner as deliveries can be by registering + # a preview interceptor that has a previewing_email method: + # + # class CssInlineStyler + # def self.previewing_email(message) + # # inline CSS styles + # end + # end + # + # config.action_mailer.preview_interceptors :css_inline_styler + # + # Note that interceptors need to be registered both with register_interceptor + # and register_preview_interceptor if they should operate on both sending and + # previewing emails. + # + # == Configuration options # # These options are specified on the class level, like # ActionMailer::Base.raise_delivery_errors = true # - # * default - You can pass this in at a class level as well as within the class itself as + # * default_options - You can pass this in at a class level as well as within the class itself as # per the above section. # # * logger - the logger is used for generating information on the mailing run if available. - # Can be set to nil for no logging. Compatible with both Ruby's own Logger and Log4r loggers. + # Can be set to +nil+ for no logging. Compatible with both Ruby's own +Logger+ and Log4r loggers. # # * smtp_settings - Allows detailed configuration for :smtp delivery method: # * :address - Allows you to use a remote mail server. Just change it from its default @@ -287,19 +430,24 @@ module ActionMailer #:nodoc: # * :password - If your mail server requires authentication, set the password in this setting. # * :authentication - If your mail server requires authentication, you need to specify the # authentication type here. - # This is a symbol and one of :plain (will send the password in the clear), :login (will - # send password Base64 encoded) or :cram_md5 (combines a Challenge/Response mechanism to exchange + # This is a symbol and one of :plain (will send the password Base64 encoded), :login (will + # send the password Base64 encoded) or :cram_md5 (combines a Challenge/Response mechanism to exchange # information and a cryptographic Message Digest 5 algorithm to hash important information) - # * :enable_starttls_auto - When set to true, detects if STARTTLS is enabled in your SMTP server - # and starts to use it. + # * :enable_starttls - Use STARTTLS when connecting to your SMTP server and fail if unsupported. Defaults + # to false. Requires at least version 2.7 of the Mail gem. + # * :enable_starttls_auto - Detects if STARTTLS is enabled in your SMTP server and starts + # to use it. Defaults to true. # * :openssl_verify_mode - When using TLS, you can set how OpenSSL checks the certificate. This is # really useful if you need to validate a self-signed and/or a wildcard certificate. You can use the name - # of an OpenSSL verify constant ('none', 'peer', 'client_once','fail_if_no_peer_cert') or directly the - # constant (OpenSSL::SSL::VERIFY_NONE, OpenSSL::SSL::VERIFY_PEER,...). + # of an OpenSSL verify constant ('none' or 'peer') or directly the constant + # (+OpenSSL::SSL::VERIFY_NONE+ or +OpenSSL::SSL::VERIFY_PEER+). + # * :ssl/:tls Enables the SMTP connection to use SMTP/TLS (SMTPS: SMTP over direct TLS connection) + # * :open_timeout Number of seconds to wait while attempting to open a connection. + # * :read_timeout Number of seconds to wait until timing-out a read(2) call. # # * sendmail_settings - Allows you to override options for the :sendmail delivery method. # * :location - The location of the sendmail executable. Defaults to /usr/sbin/sendmail. - # * :arguments - The command line arguments. Defaults to -i -t with -f sender@address + # * :arguments - The command line arguments. Defaults to %w[ -i ] with -f sender@address # added automatically before the message is sent. # # * file_settings - Allows you to override options for the :file delivery method. @@ -310,39 +458,52 @@ module ActionMailer #:nodoc: # # * delivery_method - Defines a delivery method. Possible values are :smtp (default), # :sendmail, :test, and :file. Or you may provide a custom delivery method - # object eg. MyOwnDeliveryMethodClass.new. See the Mail gem documentation on the interface you need to + # object e.g. +MyOwnDeliveryMethodClass+. See the Mail gem documentation on the interface you need to # implement for a custom delivery agent. # # * perform_deliveries - Determines whether emails are actually sent from Action Mailer when you - # call .deliver on an mail message or on an Action Mailer method. This is on by default but can + # call .deliver on an email message or on an Action Mailer method. This is on by default but can # be turned off to aid in functional testing. # # * deliveries - Keeps an array of all the emails sent out through the Action Mailer with # delivery_method :test. Most useful for unit and functional testing. # + # * delivery_job - The job class used with deliver_later. Mailers can set this to use a + # custom delivery job. Defaults to +ActionMailer::MailDeliveryJob+. + # + # * deliver_later_queue_name - The queue name used by deliver_later with the default + # delivery_job. Mailers can set this to use a custom queue name. class Base < AbstractController::Base + include Callbacks include DeliveryMethods + include QueuedDelivery + include Rescuable + include Parameterized + include Previews + include FormBuilder + abstract! - include AbstractController::Logger include AbstractController::Rendering - include AbstractController::Layouts + + include AbstractController::Logger include AbstractController::Helpers include AbstractController::Translation include AbstractController::AssetPaths + include AbstractController::Callbacks + include AbstractController::Caching - self.protected_instance_variables = [:@_action_has_layout] + include ActionView::Layouts - helper ActionMailer::MailHelper + PROTECTED_IVARS = AbstractController::Rendering::DEFAULT_PROTECTED_INSTANCE_VARIABLES + [:@_action_has_layout] - private_class_method :new #:nodoc: + helper ActionMailer::MailHelper - class_attribute :default_params - self.default_params = { - :mime_version => "1.0", - :charset => "UTF-8", - :content_type => "text/plain", - :parts_order => [ "text/plain", "text/enriched", "text/html" ] + class_attribute :default_params, default: { + mime_version: "1.0", + charset: "UTF-8", + content_type: "text/plain", + parts_order: [ "text/plain", "text/enriched", "text/html" ] }.freeze class << self @@ -351,128 +512,209 @@ def register_observers(*observers) observers.flatten.compact.each { |observer| register_observer(observer) } end + # Unregister one or more previously registered Observers. + def unregister_observers(*observers) + observers.flatten.compact.each { |observer| unregister_observer(observer) } + end + # Register one or more Interceptors which will be called before mail is sent. def register_interceptors(*interceptors) interceptors.flatten.compact.each { |interceptor| register_interceptor(interceptor) } end + # Unregister one or more previously registered Interceptors. + def unregister_interceptors(*interceptors) + interceptors.flatten.compact.each { |interceptor| unregister_interceptor(interceptor) } + end + # Register an Observer which will be notified when mail is delivered. - # Either a class or a string can be passed in as the Observer. If a string is passed in - # it will be constantized. + # Either a class, string, or symbol can be passed in as the Observer. + # If a string or symbol is passed in it will be camelized and constantized. def register_observer(observer) - delivery_observer = (observer.is_a?(String) ? observer.constantize : observer) - Mail.register_observer(delivery_observer) + Mail.register_observer(observer_class_for(observer)) + end + + # Unregister a previously registered Observer. + # Either a class, string, or symbol can be passed in as the Observer. + # If a string or symbol is passed in it will be camelized and constantized. + def unregister_observer(observer) + Mail.unregister_observer(observer_class_for(observer)) end # Register an Interceptor which will be called before mail is sent. - # Either a class or a string can be passed in as the Interceptor. If a string is passed in - # it will be constantized. + # Either a class, string, or symbol can be passed in as the Interceptor. + # If a string or symbol is passed in it will be camelized and constantized. def register_interceptor(interceptor) - delivery_interceptor = (interceptor.is_a?(String) ? interceptor.constantize : interceptor) - Mail.register_interceptor(delivery_interceptor) + Mail.register_interceptor(observer_class_for(interceptor)) + end + + # Unregister a previously registered Interceptor. + # Either a class, string, or symbol can be passed in as the Interceptor. + # If a string or symbol is passed in it will be camelized and constantized. + def unregister_interceptor(interceptor) + Mail.unregister_interceptor(observer_class_for(interceptor)) + end + + def observer_class_for(value) # :nodoc: + case value + when String, Symbol + value.to_s.camelize.constantize + else + value + end end + private :observer_class_for + # Returns the name of the current mailer. This method is also being used as a path for a view lookup. + # If this is an anonymous mailer, this method will return +anonymous+ instead. def mailer_name - @mailer_name ||= name.underscore + @mailer_name ||= anonymous? ? "anonymous" : name.underscore end + # Allows to set the name of current mailer. attr_writer :mailer_name alias :controller_path :mailer_name + # Allows to set defaults through app configuration: + # + # config.action_mailer.default_options = { from: "no-reply@example.org" } def default(value = nil) self.default_params = default_params.merge(value).freeze if value default_params end + alias :default_options= :default - # Receives a raw email, parses it into an email object, decodes it, - # instantiates a new mailer, and passes the email object to the mailer - # object's +receive+ method. If you want your mailer to be able to - # process incoming messages, you'll need to implement a +receive+ - # method that accepts the raw email string as a parameter: + # Wraps an email delivery inside of ActiveSupport::Notifications instrumentation. # - # class MyMailer < ActionMailer::Base - # def receive(mail) - # ... - # end - # end - def receive(raw_mail) - ActiveSupport::Notifications.instrument("receive.action_mailer") do |payload| - mail = Mail.new(raw_mail) - set_payload_for_mail(payload, mail) - new.receive(mail) - end - end - - # Wraps an email delivery inside of Active Support Notifications instrumentation. This - # method is actually called by the Mail::Message object itself through a callback - # when you call :deliver on the Mail::Message, calling +deliver_mail+ directly - # and passing a Mail::Message will do nothing except tell the logger you sent the email. - def deliver_mail(mail) #:nodoc: + # This method is actually called by the +Mail::Message+ object itself + # through a callback when you call :deliver on the +Mail::Message+, + # calling +deliver_mail+ directly and passing a +Mail::Message+ will do + # nothing except tell the logger you sent the email. + def deliver_mail(mail) # :nodoc: ActiveSupport::Notifications.instrument("deliver.action_mailer") do |payload| set_payload_for_mail(payload, mail) yield # Let Mail do the delivery actions end end - def respond_to?(method, include_private = false) #:nodoc: - super || action_methods.include?(method.to_s) + # Returns an email in the format "Name ". + # + # If the name is a blank string, it returns just the address. + def email_address_with_name(address, name) + Mail::Address.new.tap do |builder| + builder.address = address + builder.display_name = name.presence + end.to_s end - protected + private + def set_payload_for_mail(payload, mail) + payload[:mail] = mail.encoded + payload[:mailer] = name + payload[:message_id] = mail.message_id + payload[:subject] = mail.subject + payload[:to] = mail.to + payload[:from] = mail.from + payload[:bcc] = mail.bcc if mail.bcc.present? + payload[:cc] = mail.cc if mail.cc.present? + payload[:date] = mail.date + payload[:perform_deliveries] = mail.perform_deliveries + end - def set_payload_for_mail(payload, mail) #:nodoc: - payload[:mailer] = name - payload[:message_id] = mail.message_id - payload[:subject] = mail.subject - payload[:to] = mail.to - payload[:from] = mail.from - payload[:bcc] = mail.bcc if mail.bcc.present? - payload[:cc] = mail.cc if mail.cc.present? - payload[:date] = mail.date - payload[:mail] = mail.encoded + def method_missing(method_name, ...) + if action_methods.include?(method_name.name) + MessageDelivery.new(self, method_name, ...) + else + super + end end - def method_missing(method, *args) #:nodoc: - return super unless respond_to?(method) - new(method, *args).message + def respond_to_missing?(method, include_all = false) + action_methods.include?(method.name) || super end end attr_internal :message - # Instantiate a new mailer object. If +method_name+ is not +nil+, the mailer - # will be initialized according to the named method. If not, the mailer will - # remain uninitialized (useful when you only need to invoke the "receive" - # method, for instance). - def initialize(method_name=nil, *args) + def initialize super() + @_mail_was_called = false @_message = Mail.new - process(method_name, *args) if method_name end - def process(*args) #:nodoc: - lookup_context.skip_default_locale! - super + def process(method_name, *args) # :nodoc: + payload = { + mailer: self.class.name, + action: method_name, + args: args + } + + ActiveSupport::Notifications.instrument("process.action_mailer", payload) do + super + @_message = NullMail.new unless @_mail_was_called + end end + ruby2_keywords(:process) + class NullMail # :nodoc: + def body; "" end + def header; {} end + + def respond_to?(string, include_all = false) + true + end + + def method_missing(...) + nil + end + end + + # Returns the name of the mailer object. def mailer_name self.class.mailer_name end - # Allows you to pass random and unusual headers to the new +Mail::Message+ object - # which will add them to itself. + # Returns an email in the format "Name ". + # + # If the name is a blank string, it returns just the address. + def email_address_with_name(address, name) + self.class.email_address_with_name(address, name) + end + + # Allows you to pass random and unusual headers to the new +Mail::Message+ + # object which will add them to itself. # # headers['X-Special-Domain-Specific-Header'] = "SecretValue" # - # You can also pass a hash into headers of header field names and values, which - # will then be set on the Mail::Message object: + # You can also pass a hash into headers of header field names and values, + # which will then be set on the +Mail::Message+ object: # # headers 'X-Special-Domain-Specific-Header' => "SecretValue", # 'In-Reply-To' => incoming.message_id # - # The resulting Mail::Message will have the following in it's header: + # The resulting +Mail::Message+ will have the following in its header: # # X-Special-Domain-Specific-Header: SecretValue - def headers(args=nil) + # + # Note about replacing already defined headers: + # + # * +subject+ + # * +sender+ + # * +from+ + # * +to+ + # * +cc+ + # * +bcc+ + # * +reply-to+ + # * +orig-date+ + # * +message-id+ + # * +references+ + # + # Fields can only appear once in email headers while other fields such as + # X-Anything can appear multiple times. + # + # If you want to replace any header which already exists, first set it to + # +nil+ in order to reset the value otherwise another field will be added + # for the same header. + def headers(args = nil) if args @_message.headers(args) else @@ -484,23 +726,23 @@ def headers(args=nil) # # mail.attachments['filename.jpg'] = File.read('/path/to/filename.jpg') # - # If you do this, then Mail will take the file name and work out the mime type - # set the Content-Type, Content-Disposition, Content-Transfer-Encoding and - # base64 encode the contents of the attachment all for you. + # If you do this, then Mail will take the file name and work out the mime type. + # It will also set the +Content-Type+, +Content-Disposition+, and +Content-Transfer-Encoding+, + # and encode the contents of the attachment in Base64. # # You can also specify overrides if you want by passing a hash instead of a string: # - # mail.attachments['filename.jpg'] = {:mime_type => 'application/x-gzip', - # :content => File.read('/path/to/filename.jpg')} + # mail.attachments['filename.jpg'] = {mime_type: 'application/gzip', + # content: File.read('/path/to/filename.jpg')} # - # If you want to use a different encoding than Base64, you can pass an encoding in, - # but then it is up to you to pass in the content pre-encoded, and don't expect - # Mail to know how to decode this data: + # If you want to use encoding other than Base64 then you will need to pass encoding + # type along with the pre-encoded content as Mail doesn't know how to decode the + # data: # # file_content = SpecialEncode(File.read('/path/to/filename.jpg')) - # mail.attachments['filename.jpg'] = {:mime_type => 'application/x-gzip', - # :encoding => 'SpecialEncoding', - # :content => file_content } + # mail.attachments['filename.jpg'] = {mime_type: 'application/gzip', + # encoding: 'SpecialEncoding', + # content: file_content } # # You can also search for specific attachments: # @@ -511,222 +753,319 @@ def headers(args=nil) # mail.attachments[0] # => Mail::Part (first attachment) # def attachments - @_message.attachments + if @_mail_was_called + LateAttachmentsProxy.new(@_message.attachments) + else + @_message.attachments + end + end + + class LateAttachmentsProxy < SimpleDelegator + def inline; self end + def []=(_name, _content); _raise_error end + + private + def _raise_error + raise RuntimeError, "Can't add attachments after `mail` was called.\n" \ + "Make sure to use `attachments[]=` before calling `mail`." + end end # The main method that creates the message and renders the email templates. There are # two ways to call this method, with a block, or without a block. # - # Both methods accept a headers hash. This hash allows you to specify the most used headers - # in an email message, these are: + # It accepts a headers hash. This hash allows you to specify + # the most used headers in an email message, these are: # - # * :subject - The subject of the message, if this is omitted, Action Mailer will - # ask the Rails I18n class for a translated :subject in the scope of + # * +:subject+ - The subject of the message, if this is omitted, Action Mailer will + # ask the \Rails I18n class for a translated +:subject+ in the scope of # [mailer_scope, action_name] or if this is missing, will translate the - # humanized version of the action_name - # * :to - Who the message is destined for, can be a string of addresses, or an array + # humanized version of the +action_name+ + # * +:to+ - Who the message is destined for, can be a string of addresses, or an array # of addresses. - # * :from - Who the message is from - # * :cc - Who you would like to Carbon-Copy on this email, can be a string of addresses, + # * +:from+ - Who the message is from + # * +:cc+ - Who you would like to Carbon-Copy on this email, can be a string of addresses, # or an array of addresses. - # * :bcc - Who you would like to Blind-Carbon-Copy on this email, can be a string of + # * +:bcc+ - Who you would like to Blind-Carbon-Copy on this email, can be a string of # addresses, or an array of addresses. - # * :reply_to - Who to set the Reply-To header of the email to. - # * :date - The date to say the email was sent on. + # * +:reply_to+ - Who to set the +Reply-To+ header of the email to. + # * +:date+ - The date to say the email was sent on. # - # You can set default values for any of the above headers (except :date) by using the default - # class method: + # You can set default values for any of the above headers (except +:date+) + # by using the ::default class method: # # class Notifier < ActionMailer::Base - # self.default :from => 'no-reply@test.lindsaar.net', - # :bcc => 'email_logger@test.lindsaar.net', - # :reply_to => 'bounces@test.lindsaar.net' + # default from: 'no-reply@test.lindsaar.net', + # bcc: 'email_logger@test.lindsaar.net', + # reply_to: 'bounces@test.lindsaar.net' # end # # If you need other headers not listed above, you can either pass them in # as part of the headers hash or use the headers['name'] = value # method. # - # When a :return_path is specified as header, that value will be used as the 'envelope from' - # address for the Mail message. Setting this is useful when you want delivery notifications - # sent to a different address than the one in :from. Mail will actually use the - # :return_path in preference to the :sender in preference to the :from - # field for the 'envelope from' value. + # When a +:return_path+ is specified as header, that value will be used as + # the 'envelope from' address for the Mail message. Setting this is useful + # when you want delivery notifications sent to a different address than the + # one in +:from+. Mail will actually use the +:return_path+ in preference + # to the +:sender+ in preference to the +:from+ field for the 'envelope + # from' value. # - # If you do not pass a block to the +mail+ method, it will find all templates in the - # view paths using by default the mailer name and the method name that it is being - # called from, it will then create parts for each of these templates intelligently, - # making educated guesses on correct content type and sequence, and return a fully - # prepared Mail::Message ready to call :deliver on to send. + # If you do not pass a block to the +mail+ method, it will find all + # templates in the view paths using by default the mailer name and the + # method name that it is being called from, it will then create parts for + # each of these templates intelligently, making educated guesses on correct + # content type and sequence, and return a fully prepared +Mail::Message+ + # ready to call :deliver on to send. # # For example: # # class Notifier < ActionMailer::Base - # default :from => 'no-reply@test.lindsaar.net', + # default from: 'no-reply@test.lindsaar.net' # # def welcome - # mail(:to => 'mikel@test.lindsaar.net') + # mail(to: 'mikel@test.lindsaar.net') # end # end # - # Will look for all templates at "app/views/notifier" with name "welcome". However, those - # can be customized: + # Will look for all templates at "app/views/notifier" with name "welcome". + # If no welcome template exists, it will raise an ActionView::MissingTemplate error. + # + # However, those can be customized: # - # mail(:template_path => 'notifications', :template_name => 'another') + # mail(template_path: 'notifications', template_name: 'another') # # And now it will look for all templates at "app/views/notifications" with name "another". # # If you do pass a block, you can render specific templates of your choice: # - # mail(:to => 'mikel@test.lindsaar.net') do |format| + # mail(to: 'mikel@test.lindsaar.net') do |format| # format.text # format.html # end # - # You can even render text directly without using a template: + # You can even render plain text directly without using a template: # - # mail(:to => 'mikel@test.lindsaar.net') do |format| - # format.text { render :text => "Hello Mikel!" } - # format.html { render :text => "

Hello Mikel!

" } + # mail(to: 'mikel@test.lindsaar.net') do |format| + # format.text { render plain: "Hello Mikel!" } + # format.html { render html: "

Hello Mikel!

".html_safe } # end # - # Which will render a multipart/alternative email with text/plain and - # text/html parts. + # Which will render a +multipart/alternative+ email with +text/plain+ and + # +text/html+ parts. # # The block syntax also allows you to customize the part headers if desired: # - # mail(:to => 'mikel@test.lindsaar.net') do |format| - # format.text(:content_transfer_encoding => "base64") + # mail(to: 'mikel@test.lindsaar.net') do |format| + # format.text(content_transfer_encoding: "base64") # format.html # end # - def mail(headers={}, &block) - m = @_message + def mail(headers = {}, &block) + return message if @_mail_was_called && headers.blank? && !block - # At the beginning, do not consider class default for parts order neither content_type + # At the beginning, do not consider class default for content_type content_type = headers[:content_type] - parts_order = headers[:parts_order] - - # Call all the procs (if any) - class_default = self.class.default - default_values = class_default.merge(class_default) do |k,v| - v.respond_to?(:to_proc) ? instance_eval(&v) : v - end - # Handle defaults - headers = headers.reverse_merge(default_values) - headers[:subject] ||= default_i18n_subject + headers = apply_defaults(headers) # Apply charset at the beginning so all fields are properly quoted - m.charset = charset = headers[:charset] + message.charset = charset = headers[:charset] # Set configure delivery behavior - wrap_delivery_behavior!(headers.delete(:delivery_method)) + wrap_delivery_behavior!(headers[:delivery_method], headers[:delivery_method_options]) - # Assign all headers except parts_order, content_type and body - assignable = headers.except(:parts_order, :content_type, :body, :template_name, :template_path) - assignable.each { |k, v| m[k] = v } + assign_headers_to_message(message, headers) # Render the templates and blocks - responses, explicit_order = collect_responses_and_parts_order(headers, &block) - create_parts_from_responses(m, responses) + responses = collect_responses(headers, &block) + @_mail_was_called = true - # Setup content type, reapply charset and handle parts order - m.content_type = set_content_type(m, content_type, headers[:content_type]) - m.charset = charset + create_parts_from_responses(message, responses) + wrap_inline_attachments(message) - if m.multipart? - parts_order ||= explicit_order || headers[:parts_order] - m.body.set_sort_order(parts_order) - m.body.sort_parts! + # Set up content type, reapply charset and handle parts order + message.content_type = set_content_type(message, content_type, headers[:content_type]) + message.charset = charset + + if message.multipart? + message.body.set_sort_order(headers[:parts_order]) + message.body.sort_parts! end - m + message end - protected + private + # Used by #mail to set the content type of the message. + # + # It will use the given +user_content_type+, or multipart if the mail + # message has any attachments. If the attachments are inline, the content + # type will be "multipart/related", otherwise "multipart/mixed". + # + # If there is no content type passed in via headers, and there are no + # attachments, or the message is multipart, then the default content type is + # used. + def set_content_type(m, user_content_type, class_default) # :doc: + params = m.content_type_parameters || {} + case + when user_content_type.present? + user_content_type + when m.has_attachments? + if m.attachments.all?(&:inline?) + ["multipart", "related", params] + else + ["multipart", "mixed", params] + end + when m.multipart? + ["multipart", "alternative", params] + else + m.content_type || class_default + end + end + + # Translates the +subject+ using \Rails I18n class under [mailer_scope, action_name] scope. + # If it does not find a translation for the +subject+ under the specified scope it will default to a + # humanized version of the action_name. + # If the subject has interpolations, you can pass them through the +interpolations+ parameter. + def default_i18n_subject(interpolations = {}) # :doc: + mailer_scope = self.class.mailer_name.tr("/", ".") + I18n.t(:subject, **interpolations, scope: [mailer_scope, action_name], default: action_name.humanize) + end + + # Emails do not support relative path links. + def self.supports_path? # :doc: + false + end - def set_content_type(m, user_content_type, class_default) - params = m.content_type_parameters || {} - case - when user_content_type.present? - user_content_type - when m.has_attachments? - if m.attachments.detect { |a| a.inline? } - ["multipart", "related", params] + def apply_defaults(headers) + default_values = self.class.default.except(*headers.keys).transform_values do |value| + compute_default(value) + end + + headers_with_defaults = headers.reverse_merge(default_values) + headers_with_defaults[:subject] ||= default_i18n_subject + headers_with_defaults + end + + def compute_default(value) + return value unless value.is_a?(Proc) + + if value.arity == 1 + instance_exec(self, &value) else - ["multipart", "mixed", params] + instance_exec(&value) end - when m.multipart? - ["multipart", "alternative", params] - else - m.content_type || class_default end - end - # Translates the +subject+ using Rails I18n class under [mailer_scope, action_name] scope. - # If it does not find a translation for the +subject+ under the specified scope it will default to a - # humanized version of the action_name. - def default_i18n_subject #:nodoc: - mailer_scope = self.class.mailer_name.gsub('/', '.') - I18n.t(:subject, :scope => [mailer_scope, action_name], :default => action_name.humanize) - end + def assign_headers_to_message(message, headers) + assignable = headers.except(:parts_order, :content_type, :body, :template_name, + :template_path, :delivery_method, :delivery_method_options) + assignable.each { |k, v| message[k] = v } + end - def collect_responses_and_parts_order(headers) #:nodoc: - responses, parts_order = [], nil + def collect_responses(headers, &block) + if block_given? + collect_responses_from_block(headers, &block) + elsif headers[:body] + collect_responses_from_text(headers) + else + collect_responses_from_templates(headers) + end + end - if block_given? - collector = ActionMailer::Collector.new(lookup_context) { render(action_name) } + def collect_responses_from_block(headers) + templates_name = headers[:template_name] || action_name + collector = ActionMailer::Collector.new(lookup_context) { render(templates_name) } yield(collector) - parts_order = collector.responses.map { |r| r[:content_type] } - responses = collector.responses - elsif headers[:body] - responses << { - :body => headers.delete(:body), - :content_type => self.class.default[:content_type] || "text/plain" - } - else - templates_path = headers.delete(:template_path) || self.class.mailer_name - templates_name = headers.delete(:template_name) || action_name + collector.responses + end - each_template(templates_path, templates_name) do |template| - self.formats = template.formats + def collect_responses_from_text(headers) + [{ + body: headers.delete(:body), + content_type: headers[:content_type] || "text/plain" + }] + end + + def collect_responses_from_templates(headers) + templates_path = headers[:template_path] || self.class.mailer_name + templates_name = headers[:template_name] || action_name - responses << { - :body => render(:template => template), - :content_type => template.mime_type.to_s + each_template(Array(templates_path), templates_name).map do |template| + format = template.format || self.formats.first + { + body: render(template: template, formats: [format]), + content_type: Mime[format].to_s } end end - [responses, parts_order] - end + def each_template(paths, name, &block) + templates = lookup_context.find_all(name, paths) + if templates.empty? + raise ActionView::MissingTemplate.new(paths, name, paths, false, "mailer") + else + templates.uniq(&:format).each(&block) + end + end - def each_template(paths, name, &block) #:nodoc: - templates = lookup_context.find_all(name, Array(paths)) - templates.uniq { |t| t.formats }.each(&block) - end + def wrap_inline_attachments(message) + # If we have both types of attachment, wrap all the inline attachments + # in multipart/related, but not the actual attachments + if message.attachments.detect(&:inline?) && message.attachments.detect { |a| !a.inline? } + related = Mail::Part.new + related.content_type = "multipart/related" + mixed = [ related ] - def create_parts_from_responses(m, responses) #:nodoc: - if responses.size == 1 && !m.has_attachments? - responses[0].each { |k,v| m[k] = v } - elsif responses.size > 1 && m.has_attachments? - container = Mail::Part.new - container.content_type = "multipart/alternative" - responses.each { |r| insert_part(container, r, m.charset) } - m.add_part(container) - else - responses.each { |r| insert_part(m, r, m.charset) } + message.parts.each do |p| + if p.attachment? && !p.inline? + mixed << p + else + related.add_part(p) + end + end + + message.parts.clear + mixed.each { |c| message.add_part(c) } + end end - end - def insert_part(container, response, charset) #:nodoc: - response[:charset] ||= charset - part = Mail::Part.new(response) - container.add_part(part) - end + def create_parts_from_responses(m, responses) + if responses.size == 1 && !m.has_attachments? + responses[0].each { |k, v| m[k] = v } + elsif responses.size > 1 && m.has_attachments? + container = Mail::Part.new + container.content_type = "multipart/alternative" + responses.each { |r| insert_part(container, r, m.charset) } + m.add_part(container) + else + responses.each { |r| insert_part(m, r, m.charset) } + end + end - ActiveSupport.run_load_hooks(:action_mailer, self) + def insert_part(container, response, charset) + response[:charset] ||= charset + part = Mail::Part.new(response) + container.add_part(part) + end + + # This and #instrument_name is for caching instrument + def instrument_payload(key) + { + mailer: mailer_name, + key: key + } + end + + def instrument_name + "action_mailer" + end + + def _protected_ivars + PROTECTED_IVARS + end + + ActiveSupport.run_load_hooks(:action_mailer, self) end end - diff --git a/actionmailer/lib/action_mailer/callbacks.rb b/actionmailer/lib/action_mailer/callbacks.rb new file mode 100644 index 0000000000000..fa71f0b4f1b2e --- /dev/null +++ b/actionmailer/lib/action_mailer/callbacks.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ActionMailer + module Callbacks + extend ActiveSupport::Concern + + included do + include ActiveSupport::Callbacks + define_callbacks :deliver, skip_after_callbacks_if_terminated: true + end + + module ClassMethods + # Defines a callback that will get called right before the + # message is sent to the delivery method. + def before_deliver(*filters, &blk) + set_callback(:deliver, :before, *filters, &blk) + end + + # Defines a callback that will get called right after the + # message's delivery method is finished. + def after_deliver(*filters, &blk) + set_callback(:deliver, :after, *filters, &blk) + end + + # Defines a callback that will get called around the message's deliver method. + def around_deliver(*filters, &blk) + set_callback(:deliver, :around, *filters, &blk) + end + end + end +end diff --git a/actionmailer/lib/action_mailer/collector.rb b/actionmailer/lib/action_mailer/collector.rb index 17b22aea2af9d..888410fa755ca 100644 --- a/actionmailer/lib/action_mailer/collector.rb +++ b/actionmailer/lib/action_mailer/collector.rb @@ -1,8 +1,10 @@ -require 'abstract_controller/collector' -require 'active_support/core_ext/hash/reverse_merge' -require 'active_support/core_ext/array/extract_options' +# frozen_string_literal: true -module ActionMailer #:nodoc: +require "abstract_controller/collector" +require "active_support/core_ext/hash/reverse_merge" +require "active_support/core_ext/array/extract_options" + +module ActionMailer class Collector include AbstractController::Collector attr_reader :responses @@ -15,13 +17,13 @@ def initialize(context, &block) def any(*args, &block) options = args.extract_options! - raise "You have to supply at least one format" if args.empty? + raise ArgumentError, "You have to supply at least one format" if args.empty? args.each { |type| send(type, options.dup, &block) } end alias :all :any - def custom(mime, options={}) - options.reverse_merge!(:content_type => mime.to_s) + def custom(mime, options = {}) + options.reverse_merge!(content_type: mime.to_s) @context.formats = [mime.to_sym] options[:body] = block_given? ? yield : @default_render.call @responses << options diff --git a/actionmailer/lib/action_mailer/delivery_methods.rb b/actionmailer/lib/action_mailer/delivery_methods.rb index d1467fb5261bc..df6e90db7adcc 100644 --- a/actionmailer/lib/action_mailer/delivery_methods.rb +++ b/actionmailer/lib/action_mailer/delivery_methods.rb @@ -1,72 +1,69 @@ -require 'tmpdir' +# frozen_string_literal: true + +require "tmpdir" module ActionMailer - # This module handles everything related to mail delivery, from registering new - # delivery methods to configuring the mail object to be sent. + # = Action Mailer \DeliveryMethods + # + # This module handles everything related to mail delivery, from registering + # new delivery methods to configuring the mail object to be sent. module DeliveryMethods extend ActiveSupport::Concern included do - class_attribute :delivery_methods, :delivery_method - # Do not make this inheritable, because we always want it to propagate - cattr_accessor :raise_delivery_errors - self.raise_delivery_errors = true - - cattr_accessor :perform_deliveries - self.perform_deliveries = true + cattr_accessor :raise_delivery_errors, default: true + cattr_accessor :perform_deliveries, default: true - self.delivery_methods = {}.freeze - self.delivery_method = :smtp + class_attribute :delivery_methods, default: {}.freeze + class_attribute :delivery_method, default: :smtp add_delivery_method :smtp, Mail::SMTP, - :address => "localhost", - :port => 25, - :domain => 'localhost.localdomain', - :user_name => nil, - :password => nil, - :authentication => nil, - :enable_starttls_auto => true + address: "localhost", + port: 25, + domain: "localhost.localdomain", + user_name: nil, + password: nil, + authentication: nil, + enable_starttls_auto: true add_delivery_method :file, Mail::FileDelivery, - :location => defined?(Rails.root) ? "#{Rails.root}/tmp/mails" : "#{Dir.tmpdir}/mails" + location: defined?(Rails.root) ? "#{Rails.root}/tmp/mails" : "#{Dir.tmpdir}/mails" add_delivery_method :sendmail, Mail::Sendmail, - :location => '/usr/sbin/sendmail', - :arguments => '-i -t' + location: "/usr/sbin/sendmail", + arguments: %w[-i] add_delivery_method :test, Mail::TestMailer end + # Helpers for creating and wrapping delivery behavior, used by DeliveryMethods. module ClassMethods # Provides a list of emails that have been delivered by Mail::TestMailer - delegate :deliveries, :deliveries=, :to => Mail::TestMailer + delegate :deliveries, :deliveries=, to: Mail::TestMailer - # Adds a new delivery method through the given class using the given symbol - # as alias and the default options supplied: - # - # Example: + # Adds a new delivery method through the given class using the given + # symbol as alias and the default options supplied. # # add_delivery_method :sendmail, Mail::Sendmail, - # :location => '/usr/sbin/sendmail', - # :arguments => '-i -t' - # - def add_delivery_method(symbol, klass, default_options={}) + # location: '/usr/sbin/sendmail', + # arguments: %w[ -i ] + def add_delivery_method(symbol, klass, default_options = {}) class_attribute(:"#{symbol}_settings") unless respond_to?(:"#{symbol}_settings") - send(:"#{symbol}_settings=", default_options) + public_send(:"#{symbol}_settings=", default_options) self.delivery_methods = delivery_methods.merge(symbol.to_sym => klass).freeze end - def wrap_delivery_behavior(mail, method=nil) #:nodoc: - method ||= self.delivery_method + def wrap_delivery_behavior(mail, method = nil, options = nil) # :nodoc: + method ||= delivery_method mail.delivery_handler = self case method when NilClass raise "Delivery method cannot be nil" when Symbol - if klass = delivery_methods[method.to_sym] - mail.delivery_method(klass, send(:"#{method}_settings")) + if klass = delivery_methods[method] + mail.delivery_method(klass, (send(:"#{method}_settings") || {}).merge(options || {})) else raise "Invalid delivery method #{method.inspect}" end @@ -79,7 +76,7 @@ def wrap_delivery_behavior(mail, method=nil) #:nodoc: end end - def wrap_delivery_behavior!(*args) #:nodoc: + def wrap_delivery_behavior!(*args) # :nodoc: self.class.wrap_delivery_behavior(message, *args) end end diff --git a/actionmailer/lib/action_mailer/deprecator.rb b/actionmailer/lib/action_mailer/deprecator.rb new file mode 100644 index 0000000000000..26fde3a857ece --- /dev/null +++ b/actionmailer/lib/action_mailer/deprecator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ActionMailer + def self.deprecator # :nodoc: + @deprecator ||= ActiveSupport::Deprecation.new + end +end diff --git a/actionmailer/lib/action_mailer/form_builder.rb b/actionmailer/lib/action_mailer/form_builder.rb new file mode 100644 index 0000000000000..048ea337822fc --- /dev/null +++ b/actionmailer/lib/action_mailer/form_builder.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module ActionMailer + # = Action Mailer Form Builder + # + # Override the default form builder for all views rendered by this + # mailer and any of its descendants. Accepts a subclass of + # ActionView::Helpers::FormBuilder. + # + # While emails typically will not include forms, this can be used + # by views that are shared between controllers and mailers. + # + # For more information, see +ActionController::FormBuilder+. + module FormBuilder + extend ActiveSupport::Concern + + included do + class_attribute :_default_form_builder, instance_accessor: false + end + + module ClassMethods + # Set the form builder to be used as the default for all forms + # in the views rendered by this mailer and its subclasses. + # + # ==== Parameters + # * builder - Default form builder. Accepts a subclass of ActionView::Helpers::FormBuilder + def default_form_builder(builder) + self._default_form_builder = builder + end + end + + # Default form builder for the mailer + def default_form_builder + self.class._default_form_builder + end + end +end diff --git a/actionmailer/lib/action_mailer/gem_version.rb b/actionmailer/lib/action_mailer/gem_version.rb new file mode 100644 index 0000000000000..0317b4ebdf600 --- /dev/null +++ b/actionmailer/lib/action_mailer/gem_version.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ActionMailer + # Returns the currently loaded version of Action Mailer as a +Gem::Version+. + def self.gem_version + Gem::Version.new VERSION::STRING + end + + module VERSION + MAJOR = 8 + MINOR = 1 + TINY = 0 + PRE = "alpha" + + STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") + end +end diff --git a/actionmailer/lib/action_mailer/inline_preview_interceptor.rb b/actionmailer/lib/action_mailer/inline_preview_interceptor.rb new file mode 100644 index 0000000000000..6b47f1b961137 --- /dev/null +++ b/actionmailer/lib/action_mailer/inline_preview_interceptor.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "base64" + +module ActionMailer + # = Action Mailer \InlinePreviewInterceptor + # + # Implements a mailer preview interceptor that converts image tag src attributes + # that use inline +cid:+ style URLs to +data:+ style URLs so that they are visible + # when previewing an HTML email in a web browser. + # + # This interceptor is enabled by default. To disable it, delete it from the + # ActionMailer::Base.preview_interceptors array: + # + # ActionMailer::Base.preview_interceptors.delete(ActionMailer::InlinePreviewInterceptor) + # + class InlinePreviewInterceptor + PATTERN = /src=(?:"cid:[^"]+"|'cid:[^']+')/i + + include Base64 + + def self.previewing_email(message) # :nodoc: + new(message).transform! + end + + def initialize(message) # :nodoc: + @message = message + end + + def transform! # :nodoc: + return message if html_part.blank? + + html_part.body = html_part.decoded.gsub(PATTERN) do |match| + if part = find_part(match[9..-2]) + %[src="#{data_url(http://github.com/part)}"] + else + match + end + end + + message + end + + private + attr_reader :message + + def html_part + @html_part ||= message.html_part + end + + def data_url(http://github.com/part) + "data:#{part.mime_type};base64,#{strict_encode64(part.body.raw_source)}" + end + + def find_part(cid) + message.all_parts.find { |p| p.attachment? && p.cid == cid } + end + end +end diff --git a/actionmailer/lib/action_mailer/log_subscriber.rb b/actionmailer/lib/action_mailer/log_subscriber.rb index a6c163832e3ca..130d5d83e62df 100644 --- a/actionmailer/lib/action_mailer/log_subscriber.rb +++ b/actionmailer/lib/action_mailer/log_subscriber.rb @@ -1,16 +1,40 @@ +# frozen_string_literal: true + +require "active_support/log_subscriber" + module ActionMailer + # = Action Mailer \LogSubscriber + # + # Implements the ActiveSupport::LogSubscriber for logging notifications when + # email is delivered or received. class LogSubscriber < ActiveSupport::LogSubscriber + # An email was delivered. def deliver(event) - recipients = Array(event.payload[:to]).join(', ') - info("\nSent mail to #{recipients} (%1.fms)" % event.duration) - debug(event.payload[:mail]) + info do + if exception = event.payload[:exception_object] + "Failed delivery of mail #{event.payload[:message_id]} error_class=#{exception.class} error_message=#{exception.message.inspect}" + elsif event.payload[:perform_deliveries] + "Delivered mail #{event.payload[:message_id]} (#{event.duration.round(1)}ms)" + else + "Skipped delivery of mail #{event.payload[:message_id]} as `perform_deliveries` is false" + end + end + + debug { event.payload[:mail] } end + subscribe_log_level :deliver, :debug - def receive(event) - info("\nReceived mail (%.1fms)" % event.duration) - debug(event.payload[:mail]) + # An email was generated. + def process(event) + debug do + mailer = event.payload[:mailer] + action = event.payload[:action] + "#{mailer}##{action}: processed outbound mail in #{event.duration.round(1)}ms" + end end + subscribe_log_level :process, :debug + # Use the logger configured for ActionMailer::Base. def logger ActionMailer::Base.logger end diff --git a/actionmailer/lib/action_mailer/mail_delivery_job.rb b/actionmailer/lib/action_mailer/mail_delivery_job.rb new file mode 100644 index 0000000000000..d76a7cf1155c8 --- /dev/null +++ b/actionmailer/lib/action_mailer/mail_delivery_job.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "active_job" + +module ActionMailer + # = Action Mailer \MailDeliveryJob + # + # The +ActionMailer::MailDeliveryJob+ class is used when you + # want to send emails outside of the request-response cycle. It supports + # sending either parameterized or normal mail. + # + # Exceptions are rescued and handled by the mailer class. + class MailDeliveryJob < ActiveJob::Base # :nodoc: + queue_as do + mailer_class = arguments.first.constantize + mailer_class.deliver_later_queue_name + end + + rescue_from StandardError, with: :handle_exception_with_mailer_class + + def perform(mailer, mail_method, delivery_method, args:, kwargs: nil, params: nil) + mailer_class = params ? mailer.constantize.with(params) : mailer.constantize + message = if kwargs + mailer_class.public_send(mail_method, *args, **kwargs) + else + mailer_class.public_send(mail_method, *args) + end + message.send(delivery_method) + end + + private + # "Deserialize" the mailer class name by hand in case another argument + # (like a Global ID reference) raised DeserializationError. + def mailer_class + if mailer = Array(@serialized_arguments).first || Array(arguments).first + mailer.constantize + end + end + + def handle_exception_with_mailer_class(exception) + if klass = mailer_class + klass.handle_exception exception + else + raise exception + end + end + end +end diff --git a/actionmailer/lib/action_mailer/mail_helper.rb b/actionmailer/lib/action_mailer/mail_helper.rb index 2036883b2249d..13c8571dc45f7 100644 --- a/actionmailer/lib/action_mailer/mail_helper.rb +++ b/actionmailer/lib/action_mailer/mail_helper.rb @@ -1,16 +1,42 @@ +# frozen_string_literal: true + module ActionMailer + # = Action Mailer \MailHelper + # + # Provides helper methods for ActionMailer::Base that can be used for easily + # formatting messages, accessing mailer or message instances, and the + # attachments list. module MailHelper - # Take the text and format it, indented two spaces for each line, and wrapped at 72 columns. + # Take the text and format it, indented two spaces for each line, and + # wrapped at 72 columns: + # + # text = <<-TEXT + # This is + # the paragraph. + # + # * item1 * item2 + # TEXT + # + # block_format text + # # => " This is the paragraph.\n\n * item1\n * item2\n" def block_format(text) formatted = text.split(/\n\r?\n/).collect { |paragraph| format_paragraph(paragraph) }.join("\n\n") # Make list points stand on their own line - formatted.gsub!(/[ ]*([*]+) ([^*]*)/) { |s| " #{$1} #{$2.strip}\n" } - formatted.gsub!(/[ ]*([#]+) ([^#]*)/) { |s| " #{$1} #{$2.strip}\n" } + output = +"" + splits = formatted.split(/(\*+|\#+)/) + while line = splits.shift + if line.start_with?("*", "#") && splits.first&.start_with?(" ") + output.chomp!(" ") while output.end_with?(" ") + output << " #{line} #{splits.shift.strip}\n" + else + output << line + end + end - formatted + output end # Access the mailer instance. @@ -25,14 +51,14 @@ def message # Access the message attachments list. def attachments - @_message.attachments + mailer.attachments end # Returns +text+ wrapped at +len+ columns and indented +indent+ spaces. + # By default column length +len+ equals 72 characters and indent + # +indent+ equal two spaces. # - # === Examples - # - # my_text = "Here is a sample text with more than 40 characters" + # my_text = 'Here is a sample text with more than 40 characters' # # format_paragraph(my_text, 25, 4) # # => " Here is a sample text with\n more than 40 characters" @@ -40,15 +66,16 @@ def format_paragraph(text, len = 72, indent = 2) sentences = [[]] text.split.each do |word| - if sentences.first.present? && (sentences.last + [word]).join(' ').length > len + if sentences.first.present? && (sentences.last + [word]).join(" ").length > len sentences << [word] else sentences.last << word end end - sentences.map { |sentence| - "#{" " * indent}#{sentence.join(' ')}" + indentation = " " * indent + sentences.map! { |sentence| + "#{indentation}#{sentence.join(' ')}" }.join "\n" end end diff --git a/actionmailer/lib/action_mailer/message_delivery.rb b/actionmailer/lib/action_mailer/message_delivery.rb new file mode 100644 index 0000000000000..504bb25312765 --- /dev/null +++ b/actionmailer/lib/action_mailer/message_delivery.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require "delegate" + +module ActionMailer + # = Action Mailer \MessageDelivery + # + # The +ActionMailer::MessageDelivery+ class is used by + # ActionMailer::Base when creating a new mailer. + # MessageDelivery is a wrapper (+Delegator+ subclass) around a lazy + # created +Mail::Message+. You can get direct access to the + # +Mail::Message+, deliver the email or schedule the email to be sent + # through Active Job. + # + # Notifier.welcome(User.first) # an ActionMailer::MessageDelivery object + # Notifier.welcome(User.first).deliver_now # sends the email + # Notifier.welcome(User.first).deliver_later # enqueue email delivery as a job through Active Job + # Notifier.welcome(User.first).message # a Mail::Message object + class MessageDelivery < Delegator + def initialize(mailer_class, action, *args) # :nodoc: + @mailer_class, @action, @args = mailer_class, action, args + + # The mail is only processed if we try to call any methods on it. + # Typical usage will leave it unloaded and call deliver_later. + @processed_mailer = nil + @mail_message = nil + end + ruby2_keywords(:initialize) + + # Method calls are delegated to the Mail::Message that's ready to deliver. + def __getobj__ # :nodoc: + @mail_message ||= processed_mailer.message + end + + # Unused except for delegator internals (dup, marshalling). + def __setobj__(mail_message) # :nodoc: + @mail_message = mail_message + end + + # Returns the resulting Mail::Message + def message + __getobj__ + end + + # Was the delegate loaded, causing the mailer action to be processed? + def processed? + @processed_mailer || @mail_message + end + + # Enqueues the email to be delivered through Active Job. When the + # job runs it will send the email using +deliver_now!+. That means + # that the message will be sent bypassing checking +perform_deliveries+ + # and +raise_delivery_errors+, so use with caution. + # + # Notifier.welcome(User.first).deliver_later! + # Notifier.welcome(User.first).deliver_later!(wait: 1.hour) + # Notifier.welcome(User.first).deliver_later!(wait_until: 10.hours.from_now) + # Notifier.welcome(User.first).deliver_later!(priority: 10) + # + # Options: + # + # * :wait - Enqueue the email to be delivered with a delay + # * :wait_until - Enqueue the email to be delivered at (after) a specific date / time + # * :queue - Enqueue the email on the specified queue + # * :priority - Enqueues the email with the specified priority + # + # By default, the email will be enqueued using ActionMailer::MailDeliveryJob on + # the default queue. Mailer classes can customize the queue name used for the default + # job by assigning a +deliver_later_queue_name+ class variable, or provide a custom job + # by assigning a +delivery_job+. When a custom job is used, it controls the queue name. + # + # class AccountRegistrationMailer < ApplicationMailer + # self.delivery_job = RegistrationDeliveryJob + # end + def deliver_later!(options = {}) + enqueue_delivery :deliver_now!, options + end + + # Enqueues the email to be delivered through Active Job. When the + # job runs it will send the email using +deliver_now+. + # + # Notifier.welcome(User.first).deliver_later + # Notifier.welcome(User.first).deliver_later(wait: 1.hour) + # Notifier.welcome(User.first).deliver_later(wait_until: 10.hours.from_now) + # Notifier.welcome(User.first).deliver_later(priority: 10) + # + # Options: + # + # * :wait - Enqueue the email to be delivered with a delay. + # * :wait_until - Enqueue the email to be delivered at (after) a specific date / time. + # * :queue - Enqueue the email on the specified queue. + # * :priority - Enqueues the email with the specified priority + # + # By default, the email will be enqueued using ActionMailer::MailDeliveryJob on + # the default queue. Mailer classes can customize the queue name used for the default + # job by assigning a +deliver_later_queue_name+ class variable, or provide a custom job + # by assigning a +delivery_job+. When a custom job is used, it controls the queue name. + # + # class AccountRegistrationMailer < ApplicationMailer + # self.delivery_job = RegistrationDeliveryJob + # end + def deliver_later(options = {}) + enqueue_delivery :deliver_now, options + end + + # Delivers an email without checking +perform_deliveries+ and +raise_delivery_errors+, + # so use with caution. + # + # Notifier.welcome(User.first).deliver_now! + # + def deliver_now! + processed_mailer.handle_exceptions do + processed_mailer.run_callbacks(:deliver) do + message.deliver! + end + end + end + + # Delivers an email: + # + # Notifier.welcome(User.first).deliver_now + # + def deliver_now + processed_mailer.handle_exceptions do + processed_mailer.run_callbacks(:deliver) do + message.deliver + end + end + end + + private + # Returns the processed Mailer instance. We keep this instance + # on hand so we can run callbacks and delegate exception handling to it. + def processed_mailer + @processed_mailer ||= @mailer_class.new.tap do |mailer| + mailer.process @action, *@args + end + end + + def enqueue_delivery(delivery_method, options = {}) + if processed? + ::Kernel.raise "You've accessed the message before asking to " \ + "deliver it later, so you may have made local changes that would " \ + "be silently lost if we enqueued a job to deliver it. Why? Only " \ + "the mailer method *arguments* are passed with the delivery job! " \ + "Do not access the message in any way if you mean to deliver it " \ + "later. Workarounds: 1. don't touch the message before calling " \ + "#deliver_later, 2. only touch the message *within your mailer " \ + "method*, or 3. use a custom Active Job instead of #deliver_later." + else + @mailer_class.delivery_job.set(options).perform_later( + @mailer_class.name, @action.to_s, delivery_method.to_s, args: @args) + end + end + end +end diff --git a/actionmailer/lib/action_mailer/parameterized.rb b/actionmailer/lib/action_mailer/parameterized.rb new file mode 100644 index 0000000000000..cbfba5f1deedc --- /dev/null +++ b/actionmailer/lib/action_mailer/parameterized.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +module ActionMailer + # = Action Mailer \Parameterized + # + # Provides the option to parameterize mailers in order to share instance variable + # setup, processing, and common headers. + # + # Consider this example that does not use parameterization: + # + # class InvitationsMailer < ApplicationMailer + # def account_invitation(inviter, invitee) + # @account = inviter.account + # @inviter = inviter + # @invitee = invitee + # + # subject = "#{@inviter.name} invited you to their Basecamp (#{@account.name})" + # + # mail \ + # subject: subject, + # to: invitee.email_address, + # from: common_address(inviter), + # reply_to: inviter.email_address_with_name + # end + # + # def project_invitation(project, inviter, invitee) + # @account = inviter.account + # @project = project + # @inviter = inviter + # @invitee = invitee + # @summarizer = ProjectInvitationSummarizer.new(@project.bucket) + # + # subject = "#{@inviter.name.familiar} added you to a project in Basecamp (#{@account.name})" + # + # mail \ + # subject: subject, + # to: invitee.email_address, + # from: common_address(inviter), + # reply_to: inviter.email_address_with_name + # end + # + # def bulk_project_invitation(projects, inviter, invitee) + # @account = inviter.account + # @projects = projects.sort_by(&:name) + # @inviter = inviter + # @invitee = invitee + # + # subject = "#{@inviter.name.familiar} added you to some new stuff in Basecamp (#{@account.name})" + # + # mail \ + # subject: subject, + # to: invitee.email_address, + # from: common_address(inviter), + # reply_to: inviter.email_address_with_name + # end + # end + # + # InvitationsMailer.account_invitation(person_a, person_b).deliver_later + # + # Using parameterized mailers, this can be rewritten as: + # + # class InvitationsMailer < ApplicationMailer + # before_action { @inviter, @invitee = params[:inviter], params[:invitee] } + # before_action { @account = params[:inviter].account } + # + # default to: -> { @invitee.email_address }, + # from: -> { common_address(@inviter) }, + # reply_to: -> { @inviter.email_address_with_name } + # + # def account_invitation + # mail subject: "#{@inviter.name} invited you to their Basecamp (#{@account.name})" + # end + # + # def project_invitation + # @project = params[:project] + # @summarizer = ProjectInvitationSummarizer.new(@project.bucket) + # + # mail subject: "#{@inviter.name.familiar} added you to a project in Basecamp (#{@account.name})" + # end + # + # def bulk_project_invitation + # @projects = params[:projects].sort_by(&:name) + # + # mail subject: "#{@inviter.name.familiar} added you to some new stuff in Basecamp (#{@account.name})" + # end + # end + # + # InvitationsMailer.with(inviter: person_a, invitee: person_b).account_invitation.deliver_later + module Parameterized + extend ActiveSupport::Concern + + included do + attr_writer :params + + def params + @params ||= {} + end + end + + module ClassMethods + # Provide the parameters to the mailer in order to use them in the instance methods and callbacks. + # + # InvitationsMailer.with(inviter: person_a, invitee: person_b).account_invitation.deliver_later + # + # See Parameterized documentation for full example. + def with(params) + ActionMailer::Parameterized::Mailer.new(self, params) + end + end + + class Mailer # :nodoc: + def initialize(mailer, params) + @mailer, @params = mailer, params + end + + private + def method_missing(method_name, ...) + if @mailer.action_methods.include?(method_name.name) + ActionMailer::Parameterized::MessageDelivery.new(@mailer, method_name, @params, ...) + else + super + end + end + + def respond_to_missing?(method, include_all = false) + @mailer.respond_to?(method, include_all) + end + end + + class MessageDelivery < ActionMailer::MessageDelivery # :nodoc: + def initialize(mailer_class, action, params, ...) + super(mailer_class, action, ...) + @params = params + end + + private + def processed_mailer + @processed_mailer ||= @mailer_class.new.tap do |mailer| + mailer.params = @params + mailer.process @action, *@args + end + end + + def enqueue_delivery(delivery_method, options = {}) + if processed? + super + else + @mailer_class.delivery_job.set(options).perform_later( + @mailer_class.name, @action.to_s, delivery_method.to_s, params: @params, args: @args) + end + end + end + end +end diff --git a/actionmailer/lib/action_mailer/preview.rb b/actionmailer/lib/action_mailer/preview.rb new file mode 100644 index 0000000000000..814f256cef1d2 --- /dev/null +++ b/actionmailer/lib/action_mailer/preview.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "active_support/descendants_tracker" + +module ActionMailer + module Previews # :nodoc: + extend ActiveSupport::Concern + + included do + # Add the location of mailer previews through app configuration: + # + # config.action_mailer.preview_paths << "#{Rails.root}/lib/mailer_previews" + # + mattr_accessor :preview_paths, instance_writer: false, default: [] + + # Enable or disable mailer previews through app configuration: + # + # config.action_mailer.show_previews = true + # + # Defaults to +true+ for development environment + # + mattr_accessor :show_previews, instance_writer: false + + # :nodoc: + mattr_accessor :preview_interceptors, instance_writer: false, default: [ActionMailer::InlinePreviewInterceptor] + end + + module ClassMethods + # Register one or more Interceptors which will be called before mail is previewed. + def register_preview_interceptors(*interceptors) + interceptors.flatten.compact.each { |interceptor| register_preview_interceptor(interceptor) } + end + + # Unregister one or more previously registered Interceptors. + def unregister_preview_interceptors(*interceptors) + interceptors.flatten.compact.each { |interceptor| unregister_preview_interceptor(interceptor) } + end + + # Register an Interceptor which will be called before mail is previewed. + # Either a class or a string can be passed in as the Interceptor. If a + # string is passed in it will be constantized. + def register_preview_interceptor(interceptor) + preview_interceptor = interceptor_class_for(interceptor) + + unless preview_interceptors.include?(preview_interceptor) + preview_interceptors << preview_interceptor + end + end + + # Unregister a previously registered Interceptor. + # Either a class or a string can be passed in as the Interceptor. If a + # string is passed in it will be constantized. + def unregister_preview_interceptor(interceptor) + preview_interceptors.delete(interceptor_class_for(interceptor)) + end + + private + def interceptor_class_for(interceptor) + case interceptor + when String, Symbol + interceptor.to_s.camelize.constantize + else + interceptor + end + end + end + end + + class Preview + extend ActiveSupport::DescendantsTracker + + attr_reader :params + + def initialize(params = {}) + @params = params + end + + class << self + # Returns all mailer preview classes. + def all + load_previews if descendants.empty? + descendants.sort_by { |mailer| mailer.name.titleize } + end + + # Returns the mail object for the given email name. The registered preview + # interceptors will be informed so that they can transform the message + # as they would if the mail was actually being delivered. + def call(email, params = {}) + preview = new(params) + message = preview.public_send(email) + inform_preview_interceptors(message) + message + end + + # Returns all of the available email previews. + def emails + public_instance_methods(false).map(&:to_s).sort + end + + # Returns +true+ if the email exists. + def email_exists?(email) + emails.include?(email) + end + + # Returns +true+ if the preview exists. + def exists?(preview) + all.any? { |p| p.preview_name == preview } + end + + # Find a mailer preview by its underscored class name. + def find(preview) + all.find { |p| p.preview_name == preview } + end + + # Returns the underscored name of the mailer preview without the suffix. + def preview_name + name.delete_suffix("Preview").underscore + end + + private + def load_previews + preview_paths.each do |preview_path| + Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require file } + end + end + + def preview_paths + Base.preview_paths + end + + def show_previews + Base.show_previews + end + + def inform_preview_interceptors(message) + Base.preview_interceptors.each do |interceptor| + interceptor.previewing_email(message) + end + end + end + end +end diff --git a/actionmailer/lib/action_mailer/queued_delivery.rb b/actionmailer/lib/action_mailer/queued_delivery.rb new file mode 100644 index 0000000000000..1624b62aa373d --- /dev/null +++ b/actionmailer/lib/action_mailer/queued_delivery.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module ActionMailer + module QueuedDelivery + extend ActiveSupport::Concern + + included do + class_attribute :delivery_job, default: ::ActionMailer::MailDeliveryJob + class_attribute :deliver_later_queue_name, default: :mailers + end + end +end diff --git a/actionmailer/lib/action_mailer/railtie.rb b/actionmailer/lib/action_mailer/railtie.rb index 5c03a29f0fc5b..1f1a41e1ce3ed 100644 --- a/actionmailer/lib/action_mailer/railtie.rb +++ b/actionmailer/lib/action_mailer/railtie.rb @@ -1,10 +1,19 @@ +# frozen_string_literal: true + +require "active_job/railtie" require "action_mailer" require "rails" require "abstract_controller/railties/routes_helpers" module ActionMailer - class Railtie < Rails::Railtie + class Railtie < Rails::Railtie # :nodoc: config.action_mailer = ActiveSupport::OrderedOptions.new + config.action_mailer.preview_paths = [] + config.eager_load_namespaces << ActionMailer + + initializer "action_mailer.deprecator", before: :load_environment_config do |app| + app.deprecators[:action_mailer] = ActionMailer.deprecator + end initializer "action_mailer.logger" do ActiveSupport.on_load(:action_mailer) { self.logger ||= Rails.logger } @@ -17,27 +26,62 @@ class Railtie < Rails::Railtie options.assets_dir ||= paths["public"].first options.javascripts_dir ||= paths["public/javascripts"].first options.stylesheets_dir ||= paths["public/stylesheets"].first + options.show_previews = Rails.env.development? if options.show_previews.nil? + options.cache_store ||= Rails.cache + options.preview_paths |= ["#{Rails.root}/test/mailers/previews"] # make sure readers methods get compiled - options.asset_path ||= app.config.asset_path options.asset_host ||= app.config.asset_host options.relative_url_root ||= app.config.relative_url_root ActiveSupport.on_load(:action_mailer) do include AbstractController::UrlFor - extend ::AbstractController::Railties::RoutesHelpers.with(app.routes) + extend ::AbstractController::Railties::RoutesHelpers.with(app.routes, false) include app.routes.mounted_helpers register_interceptors(options.delete(:interceptors)) + register_preview_interceptors(options.delete(:preview_interceptors)) register_observers(options.delete(:observers)) + self.preview_paths |= options[:preview_paths] + + if delivery_job = options.delete(:delivery_job) + self.delivery_job = delivery_job.constantize + end - options.each { |k,v| send("#{k}=", v) } + if options.smtp_settings + self.smtp_settings = options.smtp_settings + end + + smtp_timeout = options.delete(:smtp_timeout) + + if self.smtp_settings && smtp_timeout + self.smtp_settings[:open_timeout] ||= smtp_timeout + self.smtp_settings[:read_timeout] ||= smtp_timeout + end + + options.each { |k, v| send("#{k}=", v) } + end + + ActiveSupport.on_load(:action_dispatch_integration_test) do + include ActionMailer::TestHelper + include ActionMailer::TestCase::ClearTestDeliveries end end - initializer "action_mailer.compile_config_methods" do - ActiveSupport.on_load(:action_mailer) do - config.compile_methods! if config.respond_to?(:compile_methods!) + initializer "action_mailer.set_autoload_paths", before: :set_autoload_paths do |app| + options = app.config.action_mailer + app.config.paths["test/mailers/previews"].concat(options.preview_paths) + end + + config.after_initialize do |app| + options = app.config.action_mailer + + if options.show_previews + app.routes.prepend do + get "/rails/mailers" => "rails/mailers#index", internal: true + get "/rails/mailers/download/*path" => "rails/mailers#download", internal: true + get "/rails/mailers/*path" => "rails/mailers#preview", internal: true + end end end end diff --git a/actionmailer/lib/action_mailer/rescuable.rb b/actionmailer/lib/action_mailer/rescuable.rb new file mode 100644 index 0000000000000..2c881505ae27b --- /dev/null +++ b/actionmailer/lib/action_mailer/rescuable.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ActionMailer # :nodoc: + # = Action Mailer \Rescuable + # + # Provides + # {rescue_from}[rdoc-ref:ActiveSupport::Rescuable::ClassMethods#rescue_from] + # for mailers. Wraps mailer action processing, mail job processing, and mail + # delivery to handle configured errors. + module Rescuable + extend ActiveSupport::Concern + include ActiveSupport::Rescuable + + class_methods do + def handle_exception(exception) # :nodoc: + rescue_with_handler(exception) || raise(exception) + end + end + + def handle_exceptions # :nodoc: + yield + rescue => exception + rescue_with_handler(exception) || raise + end + + private + def process(...) + handle_exceptions do + super + end + end + end +end diff --git a/actionmailer/lib/action_mailer/test_case.rb b/actionmailer/lib/action_mailer/test_case.rb index 529140dfad90c..1a07d8e79242e 100644 --- a/actionmailer/lib/action_mailer/test_case.rb +++ b/actionmailer/lib/action_mailer/test_case.rb @@ -1,25 +1,48 @@ -require 'active_support/test_case' -require 'active_support/core_ext/class/attribute' +# frozen_string_literal: true + +require "active_support/test_case" +require "rails-dom-testing" module ActionMailer class NonInferrableMailerError < ::StandardError def initialize(name) - super "Unable to determine the mailer to test from #{name}. " + - "You'll need to specify it using tests YourMailer in your " + + super "Unable to determine the mailer to test from #{name}. " \ + "You'll need to specify it using tests YourMailer in your " \ "test case definition" end end class TestCase < ActiveSupport::TestCase + module ClearTestDeliveries + extend ActiveSupport::Concern + + included do + setup :clear_test_deliveries + teardown :clear_test_deliveries + end + + private + def clear_test_deliveries + if ActionMailer::Base.delivery_method == :test + ActionMailer::Base.deliveries.clear + end + end + end + module Behavior extend ActiveSupport::Concern + include ActiveSupport::Testing::ConstantLookup include TestHelper + include Rails::Dom::Testing::Assertions::SelectorAssertions + include Rails::Dom::Testing::Assertions::DomAssertions included do class_attribute :_mailer_class setup :initialize_test_deliveries setup :set_expected_mail + teardown :restore_test_deliveries + ActiveSupport.run_load_hooks(:action_mailer_test_case, self) end module ClassMethods @@ -35,7 +58,7 @@ def tests(mailer) end def mailer_class - if mailer = self._mailer_class + if mailer = _mailer_class mailer else tests determine_default_mailer(name) @@ -43,28 +66,52 @@ def mailer_class end def determine_default_mailer(name) - name.sub(/Test$/, '').constantize - rescue NameError - raise NonInferrableMailerError.new(name) + mailer = determine_constant_from_test_name(name) do |constant| + Class === constant && constant < ActionMailer::Base + end + raise NonInferrableMailerError.new(name) if mailer.nil? + mailer end end - protected + # Reads the fixture file for the given mailer. + # + # This is useful when testing mailers by being able to write the body of + # an email inside a fixture. See the testing guide for a concrete example: + # https://guides.rubyonrails.org/testing.html#revenge-of-the-fixtures + def read_fixture(action) + IO.readlines(File.join(Rails.root, "test", "fixtures", self.class.mailer_class.name.underscore, action)) + end + private def initialize_test_deliveries - ActionMailer::Base.delivery_method = :test + set_delivery_method :test + @old_perform_deliveries = ActionMailer::Base.perform_deliveries ActionMailer::Base.perform_deliveries = true ActionMailer::Base.deliveries.clear end + def restore_test_deliveries + restore_delivery_method + ActionMailer::Base.perform_deliveries = @old_perform_deliveries + end + + def set_delivery_method(method) + @old_delivery_method = ActionMailer::Base.delivery_method + ActionMailer::Base.delivery_method = method + end + + def restore_delivery_method + ActionMailer::Base.deliveries.clear + ActionMailer::Base.delivery_method = @old_delivery_method + end + def set_expected_mail @expected = Mail.new @expected.content_type ["text", "plain", { "charset" => charset }] - @expected.mime_version = '1.0' + @expected.mime_version = "1.0" end - private - def charset "UTF-8" end @@ -72,10 +119,6 @@ def charset def encode(subject) Mail::Encodings.q_value_encode(subject, charset) end - - def read_fixture(action) - IO.readlines(File.join(Rails.root, 'test', 'fixtures', self.class.mailer_class.name.underscore, action)) - end end include Behavior diff --git a/actionmailer/lib/action_mailer/test_helper.rb b/actionmailer/lib/action_mailer/test_helper.rb index 7204822395ebc..32fcb66873834 100644 --- a/actionmailer/lib/action_mailer/test_helper.rb +++ b/actionmailer/lib/action_mailer/test_helper.rb @@ -1,43 +1,51 @@ +# frozen_string_literal: true + +require "active_support/core_ext/array/extract_options" +require "active_job" + module ActionMailer + # Provides helper methods for testing Action Mailer, including #assert_emails + # and #assert_no_emails. module TestHelper + include ActiveJob::TestHelper + # Asserts that the number of emails sent matches the given number. # # def test_emails # assert_emails 0 - # ContactMailer.deliver_contact + # ContactMailer.welcome.deliver_now # assert_emails 1 - # ContactMailer.deliver_contact + # ContactMailer.welcome.deliver_now # assert_emails 2 # end # - # If a block is passed, that block should cause the specified number of emails to be sent. + # If a block is passed, that block should cause the specified number of + # emails to be sent. # # def test_emails_again # assert_emails 1 do - # ContactMailer.deliver_contact + # ContactMailer.welcome.deliver_now # end # # assert_emails 2 do - # ContactMailer.deliver_contact - # ContactMailer.deliver_contact + # ContactMailer.welcome.deliver_now + # ContactMailer.welcome.deliver_later # end # end - def assert_emails(number) + def assert_emails(number, &block) if block_given? - original_count = ActionMailer::Base.deliveries.size - yield - new_count = ActionMailer::Base.deliveries.size - assert_equal original_count + number, new_count, "#{number} emails expected, but #{new_count - original_count} were sent" + diff = capture_emails(&block).length + assert_equal number, diff, "#{number} emails expected, but #{diff} were sent" else assert_equal number, ActionMailer::Base.deliveries.size end end - # Assert that no emails have been sent. + # Asserts that no emails have been sent. # # def test_emails # assert_no_emails - # ContactMailer.deliver_contact + # ContactMailer.welcome.deliver_now # assert_emails 1 # end # @@ -51,9 +59,206 @@ def assert_emails(number) # # Note: This assertion is simply a shortcut for: # - # assert_emails 0 + # assert_emails 0, &block def assert_no_emails(&block) assert_emails 0, &block end + + # Asserts that the number of emails enqueued for later delivery matches + # the given number. + # + # def test_emails + # assert_enqueued_emails 0 + # ContactMailer.welcome.deliver_later + # assert_enqueued_emails 1 + # ContactMailer.welcome.deliver_later + # assert_enqueued_emails 2 + # end + # + # If a block is passed, that block should cause the specified number of + # emails to be enqueued. + # + # def test_emails_again + # assert_enqueued_emails 1 do + # ContactMailer.welcome.deliver_later + # end + # + # assert_enqueued_emails 2 do + # ContactMailer.welcome.deliver_later + # ContactMailer.welcome.deliver_later + # end + # end + def assert_enqueued_emails(number, &block) + assert_enqueued_jobs(number, only: ->(job) { delivery_job_filter(job) }, &block) + end + + # Asserts that a specific email has been enqueued, optionally + # matching arguments and/or params. + # + # def test_email + # ContactMailer.welcome.deliver_later + # assert_enqueued_email_with ContactMailer, :welcome + # end + # + # def test_email_with_parameters + # ContactMailer.with(greeting: "Hello").welcome.deliver_later + # assert_enqueued_email_with ContactMailer, :welcome, args: { greeting: "Hello" } + # end + # + # def test_email_with_arguments + # ContactMailer.welcome("Hello", "Goodbye").deliver_later + # assert_enqueued_email_with ContactMailer, :welcome, args: ["Hello", "Goodbye"] + # end + # + # def test_email_with_named_arguments + # ContactMailer.welcome(greeting: "Hello", farewell: "Goodbye").deliver_later + # assert_enqueued_email_with ContactMailer, :welcome, args: [{ greeting: "Hello", farewell: "Goodbye" }] + # end + # + # def test_email_with_parameters_and_arguments + # ContactMailer.with(greeting: "Hello").welcome("Cheers", "Goodbye").deliver_later + # assert_enqueued_email_with ContactMailer, :welcome, params: { greeting: "Hello" }, args: ["Cheers", "Goodbye"] + # end + # + # def test_email_with_parameters_and_named_arguments + # ContactMailer.with(greeting: "Hello").welcome(farewell: "Goodbye").deliver_later + # assert_enqueued_email_with ContactMailer, :welcome, params: { greeting: "Hello" }, args: [{farewell: "Goodbye"}] + # end + # + # def test_email_with_parameterized_mailer + # ContactMailer.with(greeting: "Hello").welcome.deliver_later + # assert_enqueued_email_with ContactMailer.with(greeting: "Hello"), :welcome + # end + # + # def test_email_with_matchers + # ContactMailer.with(greeting: "Hello").welcome("Cheers", "Goodbye").deliver_later + # assert_enqueued_email_with ContactMailer, :welcome, + # params: ->(params) { /hello/i.match?(params[:greeting]) }, + # args: ->(args) { /cheers/i.match?(args[0]) } + # end + # + # If a block is passed, that block should cause the specified email + # to be enqueued. + # + # def test_email_in_block + # assert_enqueued_email_with ContactMailer, :welcome do + # ContactMailer.welcome.deliver_later + # end + # end + # + # If +args+ is provided as a Hash, a parameterized email is matched. + # + # def test_parameterized_email + # assert_enqueued_email_with ContactMailer, :welcome, + # args: {email: 'user@example.com'} do + # ContactMailer.with(email: 'user@example.com').welcome.deliver_later + # end + # end + def assert_enqueued_email_with(mailer, method, params: nil, args: nil, queue: nil, &block) + if mailer.is_a? ActionMailer::Parameterized::Mailer + params = mailer.instance_variable_get(:@params) + mailer = mailer.instance_variable_get(:@mailer) + end + + args = Array(args) unless args.is_a?(Proc) + queue ||= mailer.deliver_later_queue_name || ActiveJob::Base.default_queue_name + + expected = ->(job_args) do + job_kwargs = job_args.extract_options! + + [mailer.to_s, method.to_s, "deliver_now"] == job_args && + params === job_kwargs[:params] && args === job_kwargs[:args] + end + + assert_enqueued_with(job: mailer.delivery_job, args: expected, queue: queue.to_s, &block) + end + + # Asserts that no emails are enqueued for later delivery. + # + # def test_no_emails + # assert_no_enqueued_emails + # ContactMailer.welcome.deliver_later + # assert_enqueued_emails 1 + # end + # + # If a block is provided, it should not cause any emails to be enqueued. + # + # def test_no_emails + # assert_no_enqueued_emails do + # # No emails should be enqueued from this block + # end + # end + def assert_no_enqueued_emails(&block) + assert_enqueued_emails 0, &block + end + + # Delivers all enqueued emails. If a block is given, delivers all of the emails + # that were enqueued throughout the duration of the block. If a block is + # not given, delivers all the enqueued emails up to this point in the test. + # + # def test_deliver_enqueued_emails + # deliver_enqueued_emails do + # ContactMailer.welcome.deliver_later + # end + # + # assert_emails 1 + # end + # + # def test_deliver_enqueued_emails_without_block + # ContactMailer.welcome.deliver_later + # + # deliver_enqueued_emails + # + # assert_emails 1 + # end + # + # If the +:queue+ option is specified, + # then only the emails(s) enqueued to a specific queue will be performed. + # + # def test_deliver_enqueued_emails_with_queue + # deliver_enqueued_emails queue: :external_mailers do + # CustomerMailer.deliver_later_queue_name = :external_mailers + # CustomerMailer.welcome.deliver_later # will be performed + # EmployeeMailer.deliver_later_queue_name = :internal_mailers + # EmployeeMailer.welcome.deliver_later # will not be performed + # end + # + # assert_emails 1 + # end + # + # If the +:at+ option is specified, then only delivers emails enqueued to deliver + # immediately or before the given time. + def deliver_enqueued_emails(queue: nil, at: nil, &block) + perform_enqueued_jobs(only: ->(job) { delivery_job_filter(job) }, queue: queue, at: at, &block) + end + + # Returns any emails that are sent in the block. + # + # def test_emails + # emails = capture_emails do + # ContactMailer.welcome.deliver_now + # end + # assert_equal "Hi there", emails.first.subject + # + # emails = capture_emails do + # ContactMailer.welcome.deliver_now + # ContactMailer.welcome.deliver_later + # end + # assert_equal "Hi there", emails.first.subject + # end + def capture_emails(&block) + original_count = ActionMailer::Base.deliveries.size + deliver_enqueued_emails(&block) + new_count = ActionMailer::Base.deliveries.size + diff = new_count - original_count + ActionMailer::Base.deliveries.last(diff) + end + + private + def delivery_job_filter(job) + job_class = job.is_a?(Hash) ? job.fetch(:job) : job.class + + Base.descendants.map(&:delivery_job).include?(job_class) + end end end diff --git a/actionmailer/lib/action_mailer/version.rb b/actionmailer/lib/action_mailer/version.rb index 0cb5dc6d6465c..9be6e66e07bb0 100644 --- a/actionmailer/lib/action_mailer/version.rb +++ b/actionmailer/lib/action_mailer/version.rb @@ -1,10 +1,11 @@ -module ActionMailer - module VERSION #:nodoc: - MAJOR = 4 - MINOR = 0 - TINY = 0 - PRE = "beta" +# frozen_string_literal: true + +require_relative "gem_version" - STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') +module ActionMailer + # Returns the currently loaded version of Action Mailer as a + # +Gem::Version+. + def self.version + gem_version end end diff --git a/actionmailer/lib/rails/generators/mailer/USAGE b/actionmailer/lib/rails/generators/mailer/USAGE index 9f1d6b182e06f..6768e5c2a2ac2 100644 --- a/actionmailer/lib/rails/generators/mailer/USAGE +++ b/actionmailer/lib/rails/generators/mailer/USAGE @@ -1,18 +1,20 @@ Description: -============ - Stubs out a new mailer and its views. Passes the mailer name, either + Generates a new mailer and its views. Passes the mailer name, either CamelCased or under_scored, and an optional list of emails as arguments. This generates a mailer class in app/mailers and invokes your template engine and test framework generators. -Example: -======== - rails generate mailer Notifications signup forgot_password invoice +Examples: + `bin/rails generate mailer sign_up` + + creates a sign up mailer class, views, and test: + Mailer: app/mailers/sign_up_mailer.rb + Views: app/views/sign_up_mailer/signup.text.erb [...] + Test: test/mailers/sign_up_mailer_test.rb + + `bin/rails generate mailer notifications sign_up forgot_password invoice` + + creates a notifications mailer with sign_up, forgot_password, and invoice actions. - creates a Notifications mailer class, views, test, and fixtures: - Mailer: app/mailers/notifications.rb - Views: app/views/notifications/signup.erb [...] - Test: test/functional/notifications_test.rb - Fixtures: test/fixtures/notifications/signup [...] diff --git a/actionmailer/lib/rails/generators/mailer/mailer_generator.rb b/actionmailer/lib/rails/generators/mailer/mailer_generator.rb index dd7fa640c9ded..c37a74c76232a 100644 --- a/actionmailer/lib/rails/generators/mailer/mailer_generator.rb +++ b/actionmailer/lib/rails/generators/mailer/mailer_generator.rb @@ -1,16 +1,38 @@ +# frozen_string_literal: true + module Rails module Generators class MailerGenerator < NamedBase - source_root File.expand_path("../templates", __FILE__) + source_root File.expand_path("templates", __dir__) + + argument :actions, type: :array, default: [], banner: "method method" - argument :actions, :type => :array, :default => [], :banner => "method method" - check_class_collision + check_class_collision suffix: "Mailer" def create_mailer_file - template "mailer.rb", File.join('app/mailers', class_path, "#{file_name}.rb") + template "mailer.rb", File.join("app/mailers", class_path, "#{file_name}_mailer.rb") + + in_root do + if behavior == :invoke && !File.exist?(application_mailer_file_name) + template "application_mailer.rb", application_mailer_file_name + end + end end hook_for :template_engine, :test_framework + + private + def file_name # :doc: + @_file_name ||= super.sub(/_mailer\z/i, "") + end + + def application_mailer_file_name + @_application_mailer_file_name ||= if mountable_engine? + "app/mailers/#{namespaced_path}/application_mailer.rb" + else + "app/mailers/application_mailer.rb" + end + end end end end diff --git a/actionmailer/lib/rails/generators/mailer/templates/application_mailer.rb.tt b/actionmailer/lib/rails/generators/mailer/templates/application_mailer.rb.tt new file mode 100644 index 0000000000000..00fb9bd48fa22 --- /dev/null +++ b/actionmailer/lib/rails/generators/mailer/templates/application_mailer.rb.tt @@ -0,0 +1,6 @@ +<% module_namespacing do -%> +class ApplicationMailer < ActionMailer::Base + default from: 'from@example.com' + layout 'mailer' +end +<% end %> diff --git a/actionmailer/lib/rails/generators/mailer/templates/mailer.rb b/actionmailer/lib/rails/generators/mailer/templates/mailer.rb deleted file mode 100644 index aaa1f79732f8f..0000000000000 --- a/actionmailer/lib/rails/generators/mailer/templates/mailer.rb +++ /dev/null @@ -1,18 +0,0 @@ -<% module_namespacing do -%> -class <%= class_name %> < ActionMailer::Base - default <%= key_value :from, '"from@example.com"' %> -<% actions.each do |action| -%> - - # Subject can be set in your I18n file at config/locales/en.yml - # with the following lookup: - # - # en.<%= file_path.gsub("/",".") %>.<%= action %>.subject - # - def <%= action %> - @greeting = "Hi" - - mail <%= key_value :to, '"to@example.org"' %> - end -<% end -%> -end -<% end -%> diff --git a/actionmailer/lib/rails/generators/mailer/templates/mailer.rb.tt b/actionmailer/lib/rails/generators/mailer/templates/mailer.rb.tt new file mode 100644 index 0000000000000..333a9f59b8144 --- /dev/null +++ b/actionmailer/lib/rails/generators/mailer/templates/mailer.rb.tt @@ -0,0 +1,19 @@ +<% module_namespacing do -%> +class <%= class_name %>Mailer < ApplicationMailer +<% actions.each_with_index do |action, index| -%> +<% if index != 0 -%> + +<% end -%> + # Subject can be set in your I18n file at config/locales/en.yml + # with the following lookup: + # + # en.<%= file_path.tr("/",".") %>_mailer.<%= action %>.subject + # + def <%= action %> + @greeting = "Hi" + + mail to: "to@example.org" + end +<% end -%> +end +<% end -%> diff --git a/actionmailer/test/abstract_unit.rb b/actionmailer/test/abstract_unit.rb index 3a519253f978a..d795e466c3003 100644 --- a/actionmailer/test/abstract_unit.rb +++ b/actionmailer/test/abstract_unit.rb @@ -1,78 +1,43 @@ -# Pathname has a warning, so require it first while silencing -# warnings to shut it up. -# -# Also, in 1.9, Bundler creates warnings due to overriding -# Rubygems methods -begin - old, $VERBOSE = $VERBOSE, nil - require 'pathname' - require File.expand_path('../../../load_paths', __FILE__) -ensure - $VERBOSE = old -end +# frozen_string_literal: true -require 'active_support/core_ext/kernel/reporting' +require_relative "../../tools/strict_warnings" +require "active_support/core_ext/kernel/reporting" # These are the normal settings that will be set up by Railties # TODO: Have these tests support other combinations of these values silence_warnings do - Encoding.default_internal = "UTF-8" - Encoding.default_external = "UTF-8" + Encoding.default_internal = Encoding::UTF_8 + Encoding.default_external = Encoding::UTF_8 end -lib = File.expand_path("#{File.dirname(__FILE__)}/../lib") -$:.unshift(lib) unless $:.include?('lib') || $:.include?(lib) +module Rails + def self.root + File.expand_path("..", __dir__) + end +end -require 'minitest/autorun' -require 'action_mailer' -require 'action_mailer/test_case' +require "active_support/testing/autorun" +require "active_support/testing/method_call_assertions" +require "action_mailer" +require "action_mailer/test_case" -silence_warnings do - # These external dependencies have warnings :/ - require 'mail' -end +# Emulate AV railtie +require "action_view" +ActionMailer::Base.include(ActionView::Layouts) # Show backtraces for deprecated behavior for quicker cleanup. -ActiveSupport::Deprecation.debug = true +ActionMailer.deprecator.debug = true -# Bogus template processors -ActionView::Template.register_template_handler :haml, lambda { |template| "Look its HAML!".inspect } -ActionView::Template.register_template_handler :bak, lambda { |template| "Lame backup".inspect } +# Disable available locale checks to avoid warnings running the test suite. +I18n.enforce_available_locales = false -FIXTURE_LOAD_PATH = File.expand_path('fixtures', File.dirname(__FILE__)) +FIXTURE_LOAD_PATH = File.expand_path("fixtures", __dir__) ActionMailer::Base.view_paths = FIXTURE_LOAD_PATH -class MockSMTP - def self.deliveries - @@deliveries - end - - def initialize - @@deliveries = [] - end - - def sendmail(mail, from, to) - @@deliveries << [mail, from, to] - end - - def start(*args) - yield self - end -end - -class Net::SMTP - def self.new(*args) - MockSMTP.new - end -end - -def set_delivery_method(method) - @old_delivery_method = ActionMailer::Base.delivery_method - ActionMailer::Base.delivery_method = method -end +ActionMailer::Base.delivery_job = ActionMailer::MailDeliveryJob -def restore_delivery_method - ActionMailer::Base.delivery_method = @old_delivery_method +class ActiveSupport::TestCase + include ActiveSupport::Testing::MethodCallAssertions end -ActiveSupport::Deprecation.silenced = true +require_relative "../../tools/test_common" diff --git a/actionmailer/test/assert_select_email_test.rb b/actionmailer/test/assert_select_email_test.rb new file mode 100644 index 0000000000000..9699fe4000ded --- /dev/null +++ b/actionmailer/test/assert_select_email_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class AssertSelectEmailTest < ActionMailer::TestCase + class AssertSelectMailer < ActionMailer::Base + def test(html) + mail body: html, content_type: "text/html", + subject: "Test e-mail", from: "test@test.host", to: "test " + end + end + + class AssertMultipartSelectMailer < ActionMailer::Base + def test(options) + mail subject: "Test e-mail", from: "test@test.host", to: "test " do |format| + format.text { render plain: options[:text] } + format.html { render plain: options[:html] } + end + end + end + + # + # Test assert_select_email + # + + def test_assert_select_email + assert_raise ActiveSupport::TestCase::Assertion do + assert_select_email { } + end + + AssertSelectMailer.test("

foo

bar

").deliver_now + assert_select_email do + assert_select "div:root" do + assert_select "p:first-child", "foo" + assert_select "p:last-child", "bar" + end + end + end + + def test_assert_select_email_multipart + AssertMultipartSelectMailer.test(html: "

foo

bar

", text: "foo bar").deliver_now + assert_select_email do + assert_select "div:root" do + assert_select "p:first-child", "foo" + assert_select "p:last-child", "bar" + end + end + end +end diff --git a/actionmailer/test/asset_host_test.rb b/actionmailer/test/asset_host_test.rb index 696a9f11749f6..7e0d7e7cc599b 100644 --- a/actionmailer/test/asset_host_test.rb +++ b/actionmailer/test/asset_host_test.rb @@ -1,22 +1,20 @@ -require 'abstract_unit' -require 'action_controller' +# frozen_string_literal: true + +require "abstract_unit" +require "action_controller" class AssetHostMailer < ActionMailer::Base def email_with_asset - mail :to => 'test@localhost', - :subject => 'testing email containing asset path while asset_host is set', - :from => 'tester@example.com' + mail to: "test@localhost", + subject: "testing email containing asset path while asset_host is set", + from: "tester@example.com" end end -class AssetHostTest < ActiveSupport::TestCase +class AssetHostTest < ActionMailer::TestCase def setup - set_delivery_method :test - ActionMailer::Base.perform_deliveries = true - ActionMailer::Base.deliveries.clear AssetHostMailer.configure do |c| c.asset_host = "http://www.example.com/" - c.assets_dir = '' end end @@ -26,31 +24,16 @@ def teardown def test_asset_host_as_string mail = AssetHostMailer.email_with_asset - assert_equal %Q{Somelogo}, mail.body.to_s.strip + assert_dom_equal '', mail.body.to_s.strip end def test_asset_host_as_one_argument_proc AssetHostMailer.config.asset_host = Proc.new { |source| - if source.starts_with?('/images') + if source.start_with?("/images") "http://images.example.com/" - else - "http://assets.example.com/" end } mail = AssetHostMailer.email_with_asset - assert_equal %Q{Somelogo}, mail.body.to_s.strip - end - - def test_asset_host_as_two_argument_proc - ActionController::Base.config.asset_host = Proc.new {|source,request| - if request && request.ssl? - "https://www.example.com/" - else - "http://www.example.com/" - end - } - mail = nil - assert_nothing_raised { mail = AssetHostMailer.email_with_asset } - assert_equal %Q{Somelogo}, mail.body.to_s.strip + assert_dom_equal '', mail.body.to_s.strip end end diff --git a/actionmailer/test/base_test.rb b/actionmailer/test/base_test.rb index 65550ab5056fe..49e0cba9fb61a 100644 --- a/actionmailer/test/base_test.rb +++ b/actionmailer/test/base_test.rb @@ -1,15 +1,29 @@ -# encoding: utf-8 -require 'abstract_unit' -require 'active_support/time' +# frozen_string_literal: true -require 'mailers/base_mailer' -require 'mailers/proc_mailer' -require 'mailers/asset_mailer' +require "abstract_unit" + +require "action_dispatch" +require "active_support/time" + +require "mailers/base_mailer" +require "mailers/proc_mailer" +require "mailers/asset_mailer" class BaseTest < ActiveSupport::TestCase - def teardown - ActionMailer::Base.asset_host = nil - ActionMailer::Base.assets_dir = nil + include Rails::Dom::Testing::Assertions::DomAssertions + + setup do + @original_delivery_method = ActionMailer::Base.delivery_method + ActionMailer::Base.delivery_method = :test + @original_asset_host = ActionMailer::Base.asset_host + @original_assets_dir = ActionMailer::Base.assets_dir + end + + teardown do + ActionMailer::Base.asset_host = @original_asset_host + ActionMailer::Base.assets_dir = @original_assets_dir + BaseMailer.deliveries.clear + ActionMailer::Base.delivery_method = @original_delivery_method end test "method call to mail does not raise error" do @@ -19,33 +33,34 @@ def teardown # Basic mail usage without block test "mail() should set the headers of the mail message" do email = BaseMailer.welcome - assert_equal(['system@test.lindsaar.net'], email.to) - assert_equal(['jose@test.plataformatec.com'], email.from) - assert_equal('The first email on new API!', email.subject) + assert_equal(["system@test.lindsaar.net"], email.to) + assert_equal(["jose@test.plataformatec.com"], email.from) + assert_equal(["mikel@test.lindsaar.net"], email.reply_to) + assert_equal("The first email on new API!", email.subject) end test "mail() with from overwrites the class level default" do - email = BaseMailer.welcome(:from => 'someone@example.com', - :to => 'another@example.org') - assert_equal(['someone@example.com'], email.from) - assert_equal(['another@example.org'], email.to) + email = BaseMailer.welcome(from: "someone@example.com", + to: "another@example.org") + assert_equal(["someone@example.com"], email.from) + assert_equal(["another@example.org"], email.to) end test "mail() with bcc, cc, content_type, charset, mime_version, reply_to and date" do time = Time.now.beginning_of_day.to_datetime - email = BaseMailer.welcome(:bcc => 'bcc@test.lindsaar.net', - :cc => 'cc@test.lindsaar.net', - :content_type => 'multipart/mixed', - :charset => 'iso-8559-1', - :mime_version => '2.0', - :reply_to => 'reply-to@test.lindsaar.net', - :date => time) - assert_equal(['bcc@test.lindsaar.net'], email.bcc) - assert_equal(['cc@test.lindsaar.net'], email.cc) - assert_equal('multipart/mixed; charset=iso-8559-1', email.content_type) - assert_equal('iso-8559-1', email.charset) - assert_equal('2.0', email.mime_version) - assert_equal(['reply-to@test.lindsaar.net'], email.reply_to) + email = BaseMailer.welcome(bcc: "bcc@test.lindsaar.net", + cc: "cc@test.lindsaar.net", + content_type: "multipart/mixed", + charset: "iso-8559-1", + mime_version: "2.0", + reply_to: "reply-to@test.lindsaar.net", + date: time) + assert_equal(["bcc@test.lindsaar.net"], email.bcc) + assert_equal(["cc@test.lindsaar.net"], email.cc) + assert_equal("multipart/mixed; charset=iso-8559-1", email.content_type) + assert_equal("iso-8559-1", email.charset) + assert_equal("2.0", email.mime_version) + assert_equal(["reply-to@test.lindsaar.net"], email.reply_to) assert_equal(time, email.date) end @@ -54,70 +69,89 @@ def teardown assert_equal("Welcome", email.body.encoded) end + test "mail() doesn't set the mailer as a controller in the execution context" do + ActiveSupport::ExecutionContext.clear + assert_nil ActiveSupport::ExecutionContext.to_h[:controller] + BaseMailer.welcome(from: "someone@example.com", to: "another@example.org").to + assert_nil ActiveSupport::ExecutionContext.to_h[:controller] + end + test "can pass in :body to the mail method hash" do - email = BaseMailer.welcome(:body => "Hello there") + email = BaseMailer.welcome(body: "Hello there") assert_equal("text/plain", email.mime_type) assert_equal("Hello there", email.body.encoded) end test "should set template content type if mail has only one part" do mail = BaseMailer.html_only - assert_equal('text/html', mail.mime_type) + assert_equal("text/html", mail.mime_type) mail = BaseMailer.plain_text_only - assert_equal('text/plain', mail.mime_type) + assert_equal("text/plain", mail.mime_type) + end + + test "mail() using email_address_with_name" do + email = BaseMailer.with_name + assert_equal("Sunny ", email["To"].value) + assert_equal("Mikel ", email["Reply-To"].value) + end + + test "mail() using email_address_with_name with blank string as name" do + email = BaseMailer.with_blank_name + assert_equal("sunny@example.com", email["To"].value) end # Custom headers test "custom headers" do email = BaseMailer.welcome - assert_equal("Not SPAM", email['X-SPAM'].decoded) + assert_equal("Not SPAM", email["X-SPAM"].decoded) end test "can pass random headers in as a hash to mail" do - hash = {'X-Special-Domain-Specific-Header' => "SecretValue", - 'In-Reply-To' => '1234@mikel.me.com' } + hash = { "X-Special-Domain-Specific-Header" => "SecretValue", + "In-Reply-To" => "<1234@mikel.me.com>" } mail = BaseMailer.welcome(hash) - assert_equal('SecretValue', mail['X-Special-Domain-Specific-Header'].decoded) - assert_equal('1234@mikel.me.com', mail['In-Reply-To'].decoded) + assert_equal("SecretValue", mail["X-Special-Domain-Specific-Header"].decoded) + assert_equal("<1234@mikel.me.com>", mail["In-Reply-To"].decoded) end test "can pass random headers in as a hash to headers" do - hash = {'X-Special-Domain-Specific-Header' => "SecretValue", - 'In-Reply-To' => '1234@mikel.me.com' } + hash = { "X-Special-Domain-Specific-Header" => "SecretValue", + "In-Reply-To" => "<1234@mikel.me.com>" } mail = BaseMailer.welcome_with_headers(hash) - assert_equal('SecretValue', mail['X-Special-Domain-Specific-Header'].decoded) - assert_equal('1234@mikel.me.com', mail['In-Reply-To'].decoded) + assert_equal("SecretValue", mail["X-Special-Domain-Specific-Header"].decoded) + assert_equal("<1234@mikel.me.com>", mail["In-Reply-To"].decoded) end # Attachments test "attachment with content" do email = BaseMailer.attachment_with_content assert_equal(1, email.attachments.length) - assert_equal('invoice.pdf', email.attachments[0].filename) - assert_equal('This is test File content', email.attachments['invoice.pdf'].decoded) + assert_equal("invoice.pdf", email.attachments[0].filename) + assert_equal("This is test File content", email.attachments["invoice.pdf"].decoded) end test "attachment gets content type from filename" do email = BaseMailer.attachment_with_content - assert_equal('invoice.pdf', email.attachments[0].filename) + assert_equal("invoice.pdf", email.attachments[0].filename) + assert_equal("application/pdf", email.attachments[0].mime_type) end test "attachment with hash" do email = BaseMailer.attachment_with_hash assert_equal(1, email.attachments.length) - assert_equal('invoice.jpg', email.attachments[0].filename) - expected = "\312\213\254\232)b" + assert_equal("invoice.jpg", email.attachments[0].filename) + expected = +"\312\213\254\232)b" expected.force_encoding(Encoding::BINARY) - assert_equal expected, email.attachments['invoice.jpg'].decoded + assert_equal expected, email.attachments["invoice.jpg"].decoded end test "attachment with hash using default mail encoding" do email = BaseMailer.attachment_with_hash_default_encoding assert_equal(1, email.attachments.length) - assert_equal('invoice.jpg', email.attachments[0].filename) - expected = "\312\213\254\232)b" + assert_equal("invoice.jpg", email.attachments[0].filename) + expected = +"\312\213\254\232)b" expected.force_encoding(Encoding::BINARY) - assert_equal expected, email.attachments['invoice.jpg'].decoded + assert_equal expected, email.attachments["invoice.jpg"].decoded end test "sets mime type to multipart/mixed when attachment is included" do @@ -126,24 +160,29 @@ def teardown assert_equal("multipart/mixed", email.mime_type) end + test "set mime type to text/html when attachment is included and body is set" do + email = BaseMailer.attachment_with_content(body: "Hello there", content_type: "text/html") + assert_equal("text/html", email.mime_type) + end + test "adds the rendered template as part" do email = BaseMailer.attachment_with_content assert_equal(2, email.parts.length) assert_equal("multipart/mixed", email.mime_type) assert_equal("text/html", email.parts[0].mime_type) - assert_equal("Attachment with content", email.parts[0].body.encoded) + assert_equal("Attachment with content", email.parts[0].decoded) assert_equal("application/pdf", email.parts[1].mime_type) - assert_equal("VGhpcyBpcyB0ZXN0IEZpbGUgY29udGVudA==\r\n", email.parts[1].body.encoded) + assert_equal("This is test File content", email.parts[1].decoded) end test "adds the given :body as part" do - email = BaseMailer.attachment_with_content(:body => "I'm the eggman") + email = BaseMailer.attachment_with_content(body: "I'm the eggman") assert_equal(2, email.parts.length) assert_equal("multipart/mixed", email.mime_type) assert_equal("text/plain", email.parts[0].mime_type) - assert_equal("I'm the eggman", email.parts[0].body.encoded) + assert_equal("I'm the eggman", email.parts[0].decoded) assert_equal("application/pdf", email.parts[1].mime_type) - assert_equal("VGhpcyBpcyB0ZXN0IEZpbGUgY29udGVudA==\r\n", email.parts[1].body.encoded) + assert_equal("This is test File content", email.parts[1].decoded) end test "can embed an inline attachment" do @@ -158,33 +197,47 @@ def teardown assert_equal("logo.png", email.parts[1].filename) end - # Defaults values + test "can embed an inline attachment and other attachments" do + email = BaseMailer.inline_and_other_attachments + # Need to call #encoded to force the JIT sort on parts + email.encoded + assert_equal(2, email.parts.length) + assert_equal("multipart/mixed", email.mime_type) + assert_equal("multipart/related", email.parts[0].mime_type) + assert_equal("multipart/alternative", email.parts[0].parts[0].mime_type) + assert_equal("text/plain", email.parts[0].parts[0].parts[0].mime_type) + assert_equal("text/html", email.parts[0].parts[0].parts[1].mime_type) + assert_equal("logo.png", email.parts[0].parts[1].filename) + assert_equal("certificate.pdf", email.parts[1].filename) + end + + # Default values test "uses default charset from class" do - with_default BaseMailer, :charset => "US-ASCII" do + with_default BaseMailer, charset: "US-ASCII" do email = BaseMailer.welcome assert_equal("US-ASCII", email.charset) - email = BaseMailer.welcome(:charset => "iso-8559-1") + email = BaseMailer.welcome(charset: "iso-8559-1") assert_equal("iso-8559-1", email.charset) end end test "uses default content type from class" do - with_default BaseMailer, :content_type => "text/html" do + with_default BaseMailer, content_type: "text/html" do email = BaseMailer.welcome assert_equal("text/html", email.mime_type) - email = BaseMailer.welcome(:content_type => "text/plain") + email = BaseMailer.welcome(content_type: "text/plain") assert_equal("text/plain", email.mime_type) end end test "uses default mime version from class" do - with_default BaseMailer, :mime_version => "2.0" do + with_default BaseMailer, mime_version: "2.0" do email = BaseMailer.welcome assert_equal("2.0", email.mime_version) - email = BaseMailer.welcome(:mime_version => "1.0") + email = BaseMailer.welcome(mime_version: "1.0") assert_equal("1.0", email.mime_version) end end @@ -197,19 +250,93 @@ def teardown end test "subject gets default from I18n" do - BaseMailer.default :subject => nil - email = BaseMailer.welcome(:subject => nil) - assert_equal "Welcome", email.subject + with_default BaseMailer, subject: nil do + email = BaseMailer.welcome(subject: nil) + assert_equal "Welcome", email.subject - I18n.backend.store_translations('en', :base_mailer => {:welcome => {:subject => "New Subject!"}}) - email = BaseMailer.welcome(:subject => nil) - assert_equal "New Subject!", email.subject + with_translation "en", base_mailer: { welcome: { subject: "New Subject!" } } do + email = BaseMailer.welcome(subject: nil) + assert_equal "New Subject!", email.subject + end + end + end + + test "default subject can have interpolations" do + with_translation "en", base_mailer: { with_subject_interpolations: { subject: "Will the real %{rapper_or_impersonator} please stand up?" } } do + email = BaseMailer.with_subject_interpolations + assert_equal "Will the real Slim Shady please stand up?", email.subject + end end test "translations are scoped properly" do - I18n.backend.store_translations('en', :base_mailer => {:email_with_translations => {:greet_user => "Hello %{name}!"}}) - email = BaseMailer.email_with_translations - assert_equal 'Hello lifo!', email.body.encoded + with_translation "en", base_mailer: { email_with_translations: { greet_user: "Hello %{name}!" } } do + email = BaseMailer.email_with_translations + assert_equal "Hello lifo!", email.body.encoded + end + end + + test "adding attachments after mail was called raises exception" do + class LateAttachmentMailer < ActionMailer::Base + def welcome + mail body: "yay", from: "welcome@example.com", to: "to@example.com" + attachments["invoice.pdf"] = "This is test File content" + end + end + + e = assert_raises(RuntimeError) { LateAttachmentMailer.welcome.message } + assert_match(/Can't add attachments after `mail` was called./, e.message) + end + + test "adding inline attachments after mail was called raises exception" do + class LateInlineAttachmentMailer < ActionMailer::Base + def welcome + mail body: "yay", from: "welcome@example.com", to: "to@example.com" + attachments.inline["invoice.pdf"] = "This is test File content" + end + end + + e = assert_raises(RuntimeError) { LateInlineAttachmentMailer.welcome.message } + assert_match(/Can't add attachments after `mail` was called./, e.message) + end + + test "accessing inline attachments after mail was called works" do + class LateInlineAttachmentAccessorMailer < ActionMailer::Base + def welcome + mail body: "yay", from: "welcome@example.com", to: "to@example.com" + attachments.inline["invoice.pdf"] + end + end + + assert_nothing_raised { LateInlineAttachmentAccessorMailer.welcome.message } + end + + test "adding inline attachments while rendering mail works" do + class LateInlineAttachmentMailer < ActionMailer::Base + def on_render + mail from: "welcome@example.com", to: "to@example.com" + end + end + + mail = LateInlineAttachmentMailer.on_render + assert_nothing_raised { mail.message } + + assert_equal ["image/jpeg; filename=controller_attachments.jpg", + "image/jpeg; filename=attachments.jpg"], mail.attachments.inline.map { |a| a["Content-Type"].to_s } + end + + test "accessing attachments works after mail was called" do + class LateAttachmentAccessorMailer < ActionMailer::Base + def welcome + attachments["invoice.pdf"] = "This is test File content" + mail body: "yay", from: "welcome@example.com", to: "to@example.com" + + unless attachments.map(&:filename) == ["invoice.pdf"] + flunk("Should allow access to attachments") + end + end + end + + assert_nothing_raised { LateAttachmentAccessorMailer.welcome.message } end # Implicit multipart @@ -223,37 +350,46 @@ def teardown assert_equal("HTML Implicit Multipart", email.parts[1].body.encoded) end + test "implicit multipart formats" do + email = BaseMailer.implicit_multipart_formats + assert_equal(2, email.parts.size) + assert_equal("multipart/alternative", email.mime_type) + assert_equal("text/plain", email.parts[0].mime_type) + assert_equal("Implicit Multipart [:text]", email.parts[0].body.encoded) + assert_equal("text/html", email.parts[1].mime_type) + assert_equal("Implicit Multipart [:html]", email.parts[1].body.encoded) + end + test "implicit multipart with sort order" do order = ["text/html", "text/plain"] - with_default BaseMailer, :parts_order => order do + with_default BaseMailer, parts_order: order do email = BaseMailer.implicit_multipart assert_equal("text/html", email.parts[0].mime_type) assert_equal("text/plain", email.parts[1].mime_type) - email = BaseMailer.implicit_multipart(:parts_order => order.reverse) + email = BaseMailer.implicit_multipart(parts_order: order.reverse) assert_equal("text/plain", email.parts[0].mime_type) assert_equal("text/html", email.parts[1].mime_type) end end test "implicit multipart with attachments creates nested parts" do - email = BaseMailer.implicit_multipart(:attachments => true) - assert_equal("application/pdf", email.parts[0].mime_type) - assert_equal("multipart/alternative", email.parts[1].mime_type) - assert_equal("text/plain", email.parts[1].parts[0].mime_type) - assert_equal("TEXT Implicit Multipart", email.parts[1].parts[0].body.encoded) - assert_equal("text/html", email.parts[1].parts[1].mime_type) - assert_equal("HTML Implicit Multipart", email.parts[1].parts[1].body.encoded) + email = BaseMailer.implicit_multipart(attachments: true) + assert_equal(%w[ application/pdf multipart/alternative ], email.parts.map(&:mime_type).sort) + multipart = email.parts.detect { |p| p.mime_type == "multipart/alternative" } + assert_equal("text/plain", multipart.parts[0].mime_type) + assert_equal("TEXT Implicit Multipart", multipart.parts[0].body.encoded) + assert_equal("text/html", multipart.parts[1].mime_type) + assert_equal("HTML Implicit Multipart", multipart.parts[1].body.encoded) end test "implicit multipart with attachments and sort order" do order = ["text/html", "text/plain"] - with_default BaseMailer, :parts_order => order do - email = BaseMailer.implicit_multipart(:attachments => true) - assert_equal("application/pdf", email.parts[0].mime_type) - assert_equal("multipart/alternative", email.parts[1].mime_type) - assert_equal("text/plain", email.parts[1].parts[1].mime_type) - assert_equal("text/html", email.parts[1].parts[0].mime_type) + with_default BaseMailer, parts_order: order do + email = BaseMailer.implicit_multipart(attachments: true) + assert_equal(%w[ application/pdf multipart/alternative ], email.parts.map(&:mime_type).sort) + multipart = email.parts.detect { |p| p.mime_type == "multipart/alternative" } + assert_equal(%w[ text/html text/plain ], multipart.parts.map(&:mime_type).sort) end end @@ -268,14 +404,38 @@ def teardown end test "implicit multipart with other locale" do - swap I18n, :locale => :pl do + swap I18n, locale: :pl do email = BaseMailer.implicit_with_locale assert_equal(2, email.parts.size) assert_equal("multipart/alternative", email.mime_type) assert_equal("text/plain", email.parts[0].mime_type) assert_equal("Implicit with locale PL TEXT", email.parts[0].body.encoded) assert_equal("text/html", email.parts[1].mime_type) - assert_equal("Implicit with locale HTML", email.parts[1].body.encoded) + assert_equal("Implicit with locale EN HTML", email.parts[1].body.encoded) + end + end + + test "implicit multipart with fallback locale" do + fallback_backend = Class.new(I18n::Backend::Simple) do + include I18n::Backend::Fallbacks + end + + begin + backend = I18n.backend + I18n.backend = fallback_backend.new + I18n.fallbacks[:"de-AT"] = [:de] + + swap I18n, locale: "de-AT" do + email = BaseMailer.implicit_with_locale + assert_equal(2, email.parts.size) + assert_equal("multipart/alternative", email.mime_type) + assert_equal("text/plain", email.parts[0].mime_type) + assert_equal("Implicit with locale DE-AT TEXT", email.parts[0].body.encoded) + assert_equal("text/html", email.parts[1].mime_type) + assert_equal("Implicit with locale DE HTML", email.parts[1].body.encoded) + end + ensure + I18n.backend = backend end end @@ -317,37 +477,24 @@ def teardown assert_not_nil(mail.content_type_parameters[:boundary]) end - test "explicit multipart does not sort order" do - order = ["text/html", "text/plain"] - with_default BaseMailer, :parts_order => order do - email = BaseMailer.explicit_multipart - assert_equal("text/plain", email.parts[0].mime_type) - assert_equal("text/html", email.parts[1].mime_type) - - email = BaseMailer.explicit_multipart(:parts_order => order.reverse) - assert_equal("text/plain", email.parts[0].mime_type) - assert_equal("text/html", email.parts[1].mime_type) - end - end - test "explicit multipart with attachments creates nested parts" do - email = BaseMailer.explicit_multipart(:attachments => true) - assert_equal("application/pdf", email.parts[0].mime_type) - assert_equal("multipart/alternative", email.parts[1].mime_type) - assert_equal("text/plain", email.parts[1].parts[0].mime_type) - assert_equal("TEXT Explicit Multipart", email.parts[1].parts[0].body.encoded) - assert_equal("text/html", email.parts[1].parts[1].mime_type) - assert_equal("HTML Explicit Multipart", email.parts[1].parts[1].body.encoded) + email = BaseMailer.explicit_multipart(attachments: true) + assert_equal(%w[ application/pdf multipart/alternative ], email.parts.map(&:mime_type).sort) + multipart = email.parts.detect { |p| p.mime_type == "multipart/alternative" } + assert_equal("text/plain", multipart.parts[0].mime_type) + assert_equal("TEXT Explicit Multipart", multipart.parts[0].body.encoded) + assert_equal("text/html", multipart.parts[1].mime_type) + assert_equal("HTML Explicit Multipart", multipart.parts[1].body.encoded) end test "explicit multipart with templates" do email = BaseMailer.explicit_multipart_templates assert_equal(2, email.parts.size) assert_equal("multipart/alternative", email.mime_type) - assert_equal("text/html", email.parts[0].mime_type) - assert_equal("HTML Explicit Multipart Templates", email.parts[0].body.encoded) - assert_equal("text/plain", email.parts[1].mime_type) - assert_equal("TEXT Explicit Multipart Templates", email.parts[1].body.encoded) + assert_equal("text/plain", email.parts[0].mime_type) + assert_equal("TEXT Explicit Multipart Templates", email.parts[0].body.encoded) + assert_equal("text/html", email.parts[1].mime_type) + assert_equal("HTML Explicit Multipart Templates", email.parts[1].body.encoded) end test "explicit multipart with format.any" do @@ -360,6 +507,13 @@ def teardown assert_equal("Format with any!", email.parts[1].body.encoded) end + test "explicit without specifying format with format.any" do + error = assert_raises(ArgumentError) do + BaseMailer.explicit_without_specifying_format_with_any.parts + end + assert_equal "You have to supply at least one format", error.message + end + test "explicit multipart with format(Hash)" do email = BaseMailer.explicit_multipart_with_options(true) email.ready_to_send! @@ -382,72 +536,97 @@ def teardown email = BaseMailer.explicit_multipart_with_one_template assert_equal(2, email.parts.size) assert_equal("multipart/alternative", email.mime_type) - assert_equal("text/html", email.parts[0].mime_type) - assert_equal("[:html]", email.parts[0].body.encoded) - assert_equal("text/plain", email.parts[1].mime_type) - assert_equal("[:text]", email.parts[1].body.encoded) + assert_equal("text/plain", email.parts[0].mime_type) + assert_equal("[:text]", email.parts[0].body.encoded) + assert_equal("text/html", email.parts[1].mime_type) + assert_equal("[:html]", email.parts[1].body.encoded) + end + + test "explicit multipart with sort order" do + order = ["text/html", "text/plain"] + with_default BaseMailer, parts_order: order do + email = BaseMailer.explicit_multipart + assert_equal("text/html", email.parts[0].mime_type) + assert_equal("text/plain", email.parts[1].mime_type) + + email = BaseMailer.explicit_multipart(parts_order: order.reverse) + assert_equal("text/plain", email.parts[0].mime_type) + assert_equal("text/html", email.parts[1].mime_type) + end end # Class level API with method missing test "should respond to action methods" do assert_respond_to BaseMailer, :welcome assert_respond_to BaseMailer, :implicit_multipart - assert !BaseMailer.respond_to?(:mail) - assert !BaseMailer.respond_to?(:headers) + assert_not_respond_to BaseMailer, :mail + assert_not_respond_to BaseMailer, :headers end test "calling just the action should return the generated mail object" do - BaseMailer.deliveries.clear email = BaseMailer.welcome assert_equal(0, BaseMailer.deliveries.length) - assert_equal('The first email on new API!', email.subject) + assert_equal("The first email on new API!", email.subject) end test "calling deliver on the action should deliver the mail object" do - BaseMailer.deliveries.clear - BaseMailer.expects(:deliver_mail).once - mail = BaseMailer.welcome.deliver - assert_instance_of Mail::Message, mail + assert_called(BaseMailer, :deliver_mail) do + mail = BaseMailer.welcome.deliver_now + assert_equal "The first email on new API!", mail.subject + end end test "calling deliver on the action should increment the deliveries collection if using the test mailer" do - BaseMailer.delivery_method = :test - BaseMailer.deliveries.clear - BaseMailer.welcome.deliver + BaseMailer.welcome.deliver_now assert_equal(1, BaseMailer.deliveries.length) end test "calling deliver, ActionMailer should yield back to mail to let it call :do_delivery on itself" do mail = Mail::Message.new - mail.expects(:do_delivery).once - BaseMailer.expects(:welcome).returns(mail) - BaseMailer.welcome.deliver + assert_called(mail, :do_delivery) do + assert_called(BaseMailer, :welcome, returns: mail) do + BaseMailer.welcome.deliver + end + end end # Rendering test "you can specify a different template for implicit render" do - mail = BaseMailer.implicit_different_template('implicit_multipart').deliver + mail = BaseMailer.implicit_different_template("implicit_multipart").deliver_now assert_equal("HTML Implicit Multipart", mail.html_part.body.decoded) assert_equal("TEXT Implicit Multipart", mail.text_part.body.decoded) end + test "you can specify a different template for multipart render" do + mail = BaseMailer.implicit_different_template_with_block("explicit_multipart_templates").deliver + assert_equal("HTML Explicit Multipart Templates", mail.html_part.body.decoded) + assert_equal("TEXT Explicit Multipart Templates", mail.text_part.body.decoded) + end + + test "should raise if missing template in implicit render" do + assert_raises ActionView::MissingTemplate do + BaseMailer.implicit_different_template("missing_template").deliver_now + end + assert_equal(0, BaseMailer.deliveries.length) + end + test "you can specify a different template for explicit render" do - mail = BaseMailer.explicit_different_template('explicit_multipart_templates').deliver + mail = BaseMailer.explicit_different_template("explicit_multipart_templates").deliver_now assert_equal("HTML Explicit Multipart Templates", mail.html_part.body.decoded) assert_equal("TEXT Explicit Multipart Templates", mail.text_part.body.decoded) end test "you can specify a different layout" do - mail = BaseMailer.different_layout('different_layout').deliver + mail = BaseMailer.different_layout("different_layout").deliver_now assert_equal("HTML -- HTML", mail.html_part.body.decoded) assert_equal("PLAIN -- PLAIN", mail.text_part.body.decoded) end test "you can specify the template path for implicit lookup" do - mail = BaseMailer.welcome_from_another_path('another.path/base_mailer').deliver + mail = BaseMailer.welcome_from_another_path("another.path/base_mailer").deliver_now assert_equal("Welcome from another path", mail.body.encoded) - mail = BaseMailer.welcome_from_another_path(['unknown/invalid', 'another.path/base_mailer']).deliver + mail = BaseMailer.welcome_from_another_path(["unknown/invalid", "another.path/base_mailer"]).deliver_now assert_equal("Welcome from another path", mail.body.encoded) end @@ -457,18 +636,33 @@ def teardown mail = AssetMailer.welcome - assert_equal(%{Dummy}, mail.body.to_s.strip) + assert_dom_equal(%{}, mail.body.to_s.strip) end test "assets tags should use a Mailer's asset_host settings when available" do - ActionMailer::Base.config.asset_host = "global.com" + ActionMailer::Base.config.asset_host = "http://global.com/" ActionMailer::Base.config.assets_dir = "global/" - AssetMailer.asset_host = "http://local.com/" + TempAssetMailer = Class.new(AssetMailer) do + self.mailer_name = "asset_mailer" + self.asset_host = "http://local.com/" + end - mail = AssetMailer.welcome + mail = TempAssetMailer.welcome + + assert_dom_equal(%{}, mail.body.to_s.strip) + end + + test "the view is not rendered when mail was never called" do + mail = BaseMailer.without_mail_call + assert_equal("", mail.body.to_s.strip) + mail.deliver_now + end - assert_equal(%{Dummy}, mail.body.to_s.strip) + test "the return value of mailer methods is not relevant" do + mail = BaseMailer.with_nil_as_return_value + assert_equal("Welcome", mail.body.to_s.strip) + mail.deliver_now end # Before and After hooks @@ -483,66 +677,166 @@ def self.delivered_email(mail) end end - test "you can register an observer to the mail object that gets informed on email delivery" do - ActionMailer::Base.register_observer(MyObserver) - mail = BaseMailer.welcome - MyObserver.expects(:delivered_email).with(mail) - mail.deliver + test "you can register and unregister an observer to the mail object that gets informed on email delivery" do + mail_side_effects do + ActionMailer::Base.register_observer(MyObserver) + mail = BaseMailer.welcome + assert_called_with(MyObserver, :delivered_email, [mail]) do + mail.deliver_now + end + + ActionMailer::Base.unregister_observer(MyObserver) + assert_not_called(MyObserver, :delivered_email, returns: mail) do + mail.deliver_now + end + end end - test "you can register an observer using its stringified name to the mail object that gets informed on email delivery" do - ActionMailer::Base.register_observer("BaseTest::MyObserver") - mail = BaseMailer.welcome - MyObserver.expects(:delivered_email).with(mail) - mail.deliver + test "you can register and unregister an observer using its stringified name to the mail object that gets informed on email delivery" do + mail_side_effects do + ActionMailer::Base.register_observer("BaseTest::MyObserver") + mail = BaseMailer.welcome + assert_called_with(MyObserver, :delivered_email, [mail]) do + mail.deliver_now + end + + ActionMailer::Base.unregister_observer("BaseTest::MyObserver") + assert_not_called(MyObserver, :delivered_email, returns: mail) do + mail.deliver_now + end + end end - test "you can register multiple observers to the mail object that both get informed on email delivery" do - ActionMailer::Base.register_observers("BaseTest::MyObserver", MySecondObserver) - mail = BaseMailer.welcome - MyObserver.expects(:delivered_email).with(mail) - MySecondObserver.expects(:delivered_email).with(mail) - mail.deliver + test "you can register and unregister an observer using its symbolized underscored name to the mail object that gets informed on email delivery" do + mail_side_effects do + ActionMailer::Base.register_observer(:"base_test/my_observer") + mail = BaseMailer.welcome + assert_called_with(MyObserver, :delivered_email, [mail]) do + mail.deliver_now + end + + ActionMailer::Base.unregister_observer(:"base_test/my_observer") + assert_not_called(MyObserver, :delivered_email, returns: mail) do + mail.deliver_now + end + end end - class MyInterceptor - def self.delivering_email(mail) + test "you can register and unregister multiple observers to the mail object that both get informed on email delivery" do + mail_side_effects do + ActionMailer::Base.register_observers("BaseTest::MyObserver", MySecondObserver) + mail = BaseMailer.welcome + assert_called_with(MyObserver, :delivered_email, [mail]) do + assert_called_with(MySecondObserver, :delivered_email, [mail]) do + mail.deliver_now + end + end + + ActionMailer::Base.unregister_observers("BaseTest::MyObserver", MySecondObserver) + assert_not_called(MyObserver, :delivered_email, returns: mail) do + mail.deliver_now + end + assert_not_called(MySecondObserver, :delivered_email, returns: mail) do + mail.deliver_now + end end end + class MyInterceptor + def self.delivering_email(mail); end + def self.previewing_email(mail); end + end + class MySecondInterceptor - def self.delivering_email(mail) + def self.delivering_email(mail); end + def self.previewing_email(mail); end + end + + test "you can register and unregister an interceptor to the mail object that gets passed the mail object before delivery" do + mail_side_effects do + ActionMailer::Base.register_interceptor(MyInterceptor) + mail = BaseMailer.welcome + assert_called_with(MyInterceptor, :delivering_email, [mail]) do + mail.deliver_now + end + + ActionMailer::Base.unregister_interceptor(MyInterceptor) + assert_not_called(MyInterceptor, :delivering_email, returns: mail) do + mail.deliver_now + end end end - test "you can register an interceptor to the mail object that gets passed the mail object before delivery" do - ActionMailer::Base.register_interceptor(MyInterceptor) - mail = BaseMailer.welcome - MyInterceptor.expects(:delivering_email).with(mail) - mail.deliver + test "you can register and unregister an interceptor using its stringified name to the mail object that gets passed the mail object before delivery" do + mail_side_effects do + ActionMailer::Base.register_interceptor("BaseTest::MyInterceptor") + mail = BaseMailer.welcome + assert_called_with(MyInterceptor, :delivering_email, [mail]) do + mail.deliver_now + end + + ActionMailer::Base.unregister_interceptor("BaseTest::MyInterceptor") + assert_not_called(MyInterceptor, :delivering_email, returns: mail) do + mail.deliver_now + end + end end - test "you can register an interceptor using its stringified name to the mail object that gets passed the mail object before delivery" do - ActionMailer::Base.register_interceptor("BaseTest::MyInterceptor") - mail = BaseMailer.welcome - MyInterceptor.expects(:delivering_email).with(mail) - mail.deliver + test "you can register and unregister an interceptor using its symbolized underscored name to the mail object that gets passed the mail object before delivery" do + mail_side_effects do + ActionMailer::Base.register_interceptor(:"base_test/my_interceptor") + mail = BaseMailer.welcome + assert_called_with(MyInterceptor, :delivering_email, [mail]) do + mail.deliver_now + end + + ActionMailer::Base.unregister_interceptor(:"base_test/my_interceptor") + assert_not_called(MyInterceptor, :delivering_email, returns: mail) do + mail.deliver_now + end + end end - test "you can register multiple interceptors to the mail object that both get passed the mail object before delivery" do - ActionMailer::Base.register_interceptors("BaseTest::MyInterceptor", MySecondInterceptor) - mail = BaseMailer.welcome - MyInterceptor.expects(:delivering_email).with(mail) - MySecondInterceptor.expects(:delivering_email).with(mail) - mail.deliver + test "you can register and unregister multiple interceptors to the mail object that both get passed the mail object before delivery" do + mail_side_effects do + ActionMailer::Base.register_interceptors("BaseTest::MyInterceptor", MySecondInterceptor) + mail = BaseMailer.welcome + assert_called_with(MyInterceptor, :delivering_email, [mail]) do + assert_called_with(MySecondInterceptor, :delivering_email, [mail]) do + mail.deliver_now + end + end + + ActionMailer::Base.unregister_interceptors("BaseTest::MyInterceptor", MySecondInterceptor) + assert_not_called(MyInterceptor, :delivering_email, returns: mail) do + mail.deliver_now + end + assert_not_called(MySecondInterceptor, :delivering_email, returns: mail) do + mail.deliver_now + end + end end test "being able to put proc's into the defaults hash and they get evaluated on mail sending" do - mail1 = ProcMailer.welcome + mail1 = ProcMailer.welcome["X-Proc-Method"] yesterday = 1.day.ago - Time.stubs(:now).returns(yesterday) - mail2 = ProcMailer.welcome - assert(mail1['X-Proc-Method'].to_s.to_i > mail2['X-Proc-Method'].to_s.to_i) + Time.stub(:now, yesterday) do + mail2 = ProcMailer.welcome["X-Proc-Method"] + assert(mail1.to_s.to_i > mail2.to_s.to_i) + end + end + + test "default values which have to_proc (e.g. symbols) should not be considered procs" do + assert(ProcMailer.welcome["x-has-to-proc"].to_s == "symbol") + end + + test "proc default values can have arity of 1 where arg is a mailer instance" do + assert_equal("complex_value", ProcMailer.welcome["X-Lambda-Arity-1-arg"].to_s) + assert_equal("complex_value", ProcMailer.welcome["X-Lambda-Arity-1-self"].to_s) + end + + test "proc default values with fixed arity of 0 can be called" do + assert_equal("0", ProcMailer.welcome["X-Lambda-Arity-0"].to_s) end test "we can call other defined methods on the class as needed" do @@ -550,32 +844,157 @@ def self.delivering_email(mail) assert_equal("Thanks for signing up this afternoon", mail.subject) end + test "proc default values are not evaluated when overridden" do + with_default BaseMailer, from: -> { flunk }, to: -> { flunk } do + email = BaseMailer.welcome(from: "overridden-from@example.com", to: "overridden-to@example.com") + assert_equal ["overridden-from@example.com"], email.from + assert_equal ["overridden-to@example.com"], email.to + end + end + + test "modifying the mail message with a before_action" do + class BeforeActionMailer < ActionMailer::Base + before_action :add_special_header! + + def welcome ; mail ; end + + private + def add_special_header! + headers("X-Special-Header" => "Wow, so special") + end + end + + assert_equal("Wow, so special", BeforeActionMailer.welcome["X-Special-Header"].to_s) + end + + test "modifying the mail message with an after_action" do + class AfterActionMailer < ActionMailer::Base + after_action :add_special_header! + + def welcome ; mail ; end + + private + def add_special_header! + headers("X-Special-Header" => "Testing") + end + end + + assert_equal("Testing", AfterActionMailer.welcome["X-Special-Header"].to_s) + end + + test "adding an inline attachment using a before_action" do + class DefaultInlineAttachmentMailer < ActionMailer::Base + before_action :add_inline_attachment! + + def welcome ; mail ; end + + private + def add_inline_attachment! + attachments.inline["footer.jpg"] = "hey there" + end + end + + mail = DefaultInlineAttachmentMailer.welcome + assert_equal("image/jpeg; filename=footer.jpg", mail.attachments.inline.first["Content-Type"].to_s) + end + test "action methods should be refreshed after defining new method" do class FooMailer < ActionMailer::Base - # this triggers action_methods - self.respond_to?(:foo) + # This triggers action_methods. + respond_to?(:foo) def notify end end - assert_equal ["notify"], FooMailer.action_methods + assert_equal Set.new(["notify"]), FooMailer.action_methods + end + + test "mailer can be anonymous" do + mailer = Class.new(ActionMailer::Base) do + def welcome + mail + end + end + + assert_equal "anonymous", mailer.mailer_name + + assert_equal "Welcome", mailer.welcome.subject + assert_equal "Anonymous mailer body", mailer.welcome.body.encoded.strip + end + + test "email_address_with_name escapes" do + address = BaseMailer.email_address_with_name("test@example.org", 'I "<3" email') + assert_equal '"I \"<3\" email" ', address + end + + test "default_from can be set" do + class DefaultFromMailer < ActionMailer::Base + default to: "system@test.lindsaar.net" + self.default_options = { from: "robert.pankowecki@gmail.com" } + + def welcome + mail(subject: "subject", body: "hello world") + end + end + + assert_equal ["robert.pankowecki@gmail.com"], DefaultFromMailer.welcome.from + end + + test "mail() without arguments serves as getter for the current mail message" do + class MailerWithCallback < ActionMailer::Base + after_action :a_callback + + def welcome + headers("X-Special-Header" => "special indeed!") + mail subject: "subject", body: "hello world", to: ["joe@example.com"] + end + + def a_callback + mail.to << "jane@example.com" + end + end + + mail = MailerWithCallback.welcome + assert_equal "subject", mail.subject + assert_equal ["joe@example.com", "jane@example.com"], mail.to + assert_equal "hello world", mail.body.encoded.strip + assert_equal "special indeed!", mail["X-Special-Header"].to_s + end + + test "notification for process" do + expected_payload = { mailer: "BaseMailer", action: :welcome, args: [{ body: "Hello there" }] } + + assert_notifications_count("process.action_mailer", 1) do + assert_notification("process.action_mailer", expected_payload) do + BaseMailer.welcome(body: "Hello there").deliver_now + end + end end - protected + test "notification for deliver" do + assert_notifications_count("deliver.action_mailer", 1) do + notification = assert_notification("deliver.action_mailer") do + BaseMailer.welcome(body: "Hello there").deliver_now + end + assert_not_nil notification.payload[:message_id] + end + end + + private # Execute the block setting the given values and restoring old values after # the block is executed. def swap(klass, new_values) old_values = {} new_values.each do |key, value| - old_values[key] = klass.send key - klass.send :"#{key}=", value + old_values[key] = klass.public_send key + klass.public_send :"#{key}=", value end yield ensure old_values.each do |key, value| - klass.send :"#{key}=", value + klass.public_send :"#{key}=", value end end @@ -586,4 +1005,148 @@ def with_default(klass, new_values) ensure klass.default_params = old end + + def mail_side_effects + old_observers = Mail.class_variable_get(:@@delivery_notification_observers) + old_delivery_interceptors = Mail.class_variable_get(:@@delivery_interceptors) + yield + ensure + Mail.class_variable_set(:@@delivery_notification_observers, old_observers) + Mail.class_variable_set(:@@delivery_interceptors, old_delivery_interceptors) + end + + def with_translation(locale, data) + I18n.backend.store_translations(locale, data) + yield + ensure + I18n.backend.reload! + end +end + +class BasePreviewInterceptorsTest < ActiveSupport::TestCase + teardown do + ActionMailer::Base.preview_interceptors.clear + end + + class BaseMailerPreview < ActionMailer::Preview + def welcome + BaseMailer.welcome + end + end + + class MyInterceptor + def self.delivering_email(mail); end + def self.previewing_email(mail); end + end + + class MySecondInterceptor + def self.delivering_email(mail); end + def self.previewing_email(mail); end + end + + test "you can register and unregister a preview interceptor to the mail object that gets passed the mail object before previewing" do + ActionMailer::Base.register_preview_interceptor(MyInterceptor) + mail = BaseMailer.welcome + stub_any_instance(BaseMailerPreview) do |instance| + instance.stub(:welcome, mail) do + assert_called_with(MyInterceptor, :previewing_email, [mail]) do + BaseMailerPreview.call(:welcome) + end + end + end + + ActionMailer::Base.unregister_preview_interceptor(MyInterceptor) + assert_not_called(MyInterceptor, :previewing_email, returns: mail) do + BaseMailerPreview.call(:welcome) + end + end + + test "you can register and unregister a preview interceptor using its stringified name to the mail object that gets passed the mail object before previewing" do + ActionMailer::Base.register_preview_interceptor("BasePreviewInterceptorsTest::MyInterceptor") + mail = BaseMailer.welcome + stub_any_instance(BaseMailerPreview) do |instance| + instance.stub(:welcome, mail) do + assert_called_with(MyInterceptor, :previewing_email, [mail]) do + BaseMailerPreview.call(:welcome) + end + end + end + + ActionMailer::Base.unregister_preview_interceptor("BasePreviewInterceptorsTest::MyInterceptor") + assert_not_called(MyInterceptor, :previewing_email, returns: mail) do + BaseMailerPreview.call(:welcome) + end + end + + test "you can register and unregister a preview interceptor using its symbolized underscored name to the mail object that gets passed the mail object before previewing" do + ActionMailer::Base.register_preview_interceptor(:"base_preview_interceptors_test/my_interceptor") + mail = BaseMailer.welcome + stub_any_instance(BaseMailerPreview) do |instance| + instance.stub(:welcome, mail) do + assert_called_with(MyInterceptor, :previewing_email, [mail]) do + BaseMailerPreview.call(:welcome) + end + end + end + + ActionMailer::Base.unregister_preview_interceptor(:"base_preview_interceptors_test/my_interceptor") + assert_not_called(MyInterceptor, :previewing_email, returns: mail) do + BaseMailerPreview.call(:welcome) + end + end + + test "you can register and unregister multiple preview interceptors to the mail object that both get passed the mail object before previewing" do + ActionMailer::Base.register_preview_interceptors("BasePreviewInterceptorsTest::MyInterceptor", MySecondInterceptor) + mail = BaseMailer.welcome + stub_any_instance(BaseMailerPreview) do |instance| + instance.stub(:welcome, mail) do + assert_called_with(MyInterceptor, :previewing_email, [mail]) do + assert_called_with(MySecondInterceptor, :previewing_email, [mail]) do + BaseMailerPreview.call(:welcome) + end + end + end + end + + ActionMailer::Base.unregister_preview_interceptors("BasePreviewInterceptorsTest::MyInterceptor", MySecondInterceptor) + assert_not_called(MyInterceptor, :previewing_email, returns: mail) do + BaseMailerPreview.call(:welcome) + end + assert_not_called(MySecondInterceptor, :previewing_email, returns: mail) do + BaseMailerPreview.call(:welcome) + end + end +end + +class PreviewTest < ActiveSupport::TestCase + class A < ActionMailer::Preview; end + + module B + class A < ActionMailer::Preview; end + class C < ActionMailer::Preview; end + end + + class C < ActionMailer::Preview; end + + test "all() returns mailers in alphabetical order" do + ActionMailer::Preview.stub(:descendants, [C, A, B::C, B::A]) do + mailers = ActionMailer::Preview.all + assert_equal [A, B::A, B::C, C], mailers + end + end +end + +class BasePreviewTest < ActiveSupport::TestCase + class BaseMailerPreview < ActionMailer::Preview + def welcome + BaseMailer.welcome(params) + end + end + + test "has access to params" do + params = { name: "World" } + + message = BaseMailerPreview.call(:welcome, params) + assert_equal "World", message["name"].decoded + end end diff --git a/actionmailer/test/caching_test.rb b/actionmailer/test/caching_test.rb new file mode 100644 index 0000000000000..1a495159b2c45 --- /dev/null +++ b/actionmailer/test/caching_test.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +require "fileutils" +require "abstract_unit" +require "mailers/base_mailer" +require "mailers/caching_mailer" + +CACHE_DIR = "test_cache" +# Don't change '/../temp/' cavalierly or you might hose something you don't want hosed +FILE_STORE_PATH = File.join(__dir__, "/../temp/", CACHE_DIR) + +class FragmentCachingMailer < ActionMailer::Base + abstract! + + def some_action; end +end + +class BaseCachingTest < ActiveSupport::TestCase + def setup + super + @store = ActiveSupport::Cache::MemoryStore.new + @mailer = FragmentCachingMailer.new + @mailer.perform_caching = true + @mailer.cache_store = @store + end +end + +class FragmentCachingTest < BaseCachingTest + def test_read_fragment_with_caching_enabled + @store.write("views/name", "value") + assert_equal "value", @mailer.read_fragment("name") + end + + def test_read_fragment_with_caching_disabled + @mailer.perform_caching = false + @store.write("views/name", "value") + assert_nil @mailer.read_fragment("name") + end + + def test_fragment_exist_with_caching_enabled + @store.write("views/name", "value") + assert @mailer.fragment_exist?("name") + assert_not @mailer.fragment_exist?("other_name") + end + + def test_fragment_exist_with_caching_disabled + @mailer.perform_caching = false + @store.write("views/name", "value") + assert_not @mailer.fragment_exist?("name") + assert_not @mailer.fragment_exist?("other_name") + end + + def test_write_fragment_with_caching_enabled + assert_nil @store.read("views/name") + assert_equal "value", @mailer.write_fragment("name", "value") + assert_equal "value", @store.read("views/name") + end + + def test_write_fragment_with_caching_disabled + assert_nil @store.read("views/name") + @mailer.perform_caching = false + assert_equal "value", @mailer.write_fragment("name", "value") + assert_nil @store.read("views/name") + end + + def test_expire_fragment_with_simple_key + @store.write("views/name", "value") + @mailer.expire_fragment "name" + assert_nil @store.read("views/name") + end + + def test_expire_fragment_with_regexp + @store.write("views/name", "value") + @store.write("views/another_name", "another_value") + @store.write("views/primalgrasp", "will not expire ;-)") + + @mailer.expire_fragment(/name/) + + assert_nil @store.read("views/name") + assert_nil @store.read("views/another_name") + assert_equal "will not expire ;-)", @store.read("views/primalgrasp") + end + + def test_fragment_for + @store.write("views/expensive", "fragment content") + fragment_computed = false + + view_context = @mailer.view_context + + buffer = "generated till now -> ".html_safe + buffer << view_context.send(:fragment_for, "expensive") { fragment_computed = true } + + assert_not fragment_computed + assert_equal "generated till now -> fragment content", buffer + end + + def test_html_safety + assert_nil @store.read("views/name") + content = "value".html_safe + assert_equal content, @mailer.write_fragment("name", content) + + cached = @store.read("views/name") + assert_equal content, cached + assert_equal String, cached.class + + html_safe = @mailer.read_fragment("name") + assert_equal content, html_safe + assert_predicate html_safe, :html_safe? + end +end + +class FunctionalFragmentCachingTest < BaseCachingTest + def setup + super + @store = ActiveSupport::Cache::MemoryStore.new + @mailer = CachingMailer.new + @mailer.perform_caching = true + @mailer.cache_store = @store + end + + def test_fragment_caching + email = @mailer.fragment_cache + expected_body = "\"Welcome\"" + + assert_match expected_body, email.body.encoded + assert_match expected_body, + @store.read("views/caching_mailer/fragment_cache:#{template_digest("caching_mailer/fragment_cache", "html")}/caching") + end + + def test_fragment_caching_in_partials + email = @mailer.fragment_cache_in_partials + expected_body = "Old fragment caching in a partial" + assert_match(expected_body, email.body.encoded) + + assert_match(expected_body, + @store.read("views/caching_mailer/_partial:#{template_digest("caching_mailer/_partial", "html")}/caching")) + end + + def test_skip_fragment_cache_digesting + email = @mailer.skip_fragment_cache_digesting + expected_body = "No Digest" + + assert_match expected_body, email.body.encoded + assert_match expected_body, @store.read("views/no_digest") + end + + def test_fragment_caching_options + time = Time.now + email = @mailer.fragment_caching_options + expected_body = "No Digest" + + assert_match expected_body, email.body.encoded + Time.stub(:now, time + 11) do + assert_nil @store.read("views/no_digest") + end + end + + def test_multipart_fragment_caching + email = @mailer.multipart_cache + + expected_text_body = "\"Welcome text\"" + expected_html_body = "\"Welcome html\"" + encoded_body = email.body.encoded + assert_match expected_text_body, encoded_body + assert_match expected_html_body, encoded_body + assert_match expected_text_body, + @store.read("views/text_caching") + assert_match expected_html_body, + @store.read("views/html_caching") + end + + def test_fragment_cache_instrumentation + @mailer.enable_fragment_cache_logging = true + + expected_payload = { + mailer: "caching_mailer", + key: [:views, "caching_mailer/fragment_cache:#{template_digest("caching_mailer/fragment_cache", "html")}", :caching] + } + + assert_notification("read_fragment.action_mailer", expected_payload) do + @mailer.fragment_cache + end + ensure + @mailer.enable_fragment_cache_logging = true + end + + private + def template_digest(name, format) + ActionView::Digestor.digest(name: name, format: format, finder: @mailer.lookup_context) + end +end + +class CacheHelperOutputBufferTest < BaseCachingTest + class MockController + def read_fragment(name, options) + false + end + + def write_fragment(name, fragment, options) + fragment + end + end + + def setup + super + end + + def test_output_buffer + output_buffer = ActionView::OutputBuffer.new + controller = MockController.new + cache_helper = Class.new do + def self.controller; end + def self.output_buffer; end + def self.output_buffer=; end + end + cache_helper.extend(ActionView::Helpers::CacheHelper) + + cache_helper.stub :controller, controller do + cache_helper.stub :output_buffer, output_buffer do + assert_nothing_raised do + cache_helper.send :fragment_for, "Test fragment name", "Test fragment", &Proc.new { nil } + end + end + end + end +end + +class ViewCacheDependencyTest < BaseCachingTest + class NoDependenciesMailer < ActionMailer::Base + end + class HasDependenciesMailer < ActionMailer::Base + view_cache_dependency { "trombone" } + view_cache_dependency { "flute" } + end + + def test_view_cache_dependencies_are_empty_by_default + assert_empty NoDependenciesMailer.new.view_cache_dependencies + end + + def test_view_cache_dependencies_are_listed_in_declaration_order + assert_equal %w(trombone flute), HasDependenciesMailer.new.view_cache_dependencies + end +end diff --git a/actionmailer/test/callbacks_test.rb b/actionmailer/test/callbacks_test.rb new file mode 100644 index 0000000000000..3a2cf8f31822c --- /dev/null +++ b/actionmailer/test/callbacks_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "mailers/callback_mailer" +require "active_support/testing/stream" + +class ActionMailerCallbacksTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + include ActiveSupport::Testing::Stream + + setup do + @previous_delivery_method = ActionMailer::Base.delivery_method + ActionMailer::Base.delivery_method = :test + CallbackMailer.rescue_from_error = nil + CallbackMailer.after_deliver_instance = nil + CallbackMailer.around_deliver_instance = nil + CallbackMailer.abort_before_deliver = nil + CallbackMailer.around_handles_error = nil + end + + teardown do + ActionMailer::Base.deliveries.clear + ActionMailer::Base.delivery_method = @previous_delivery_method + CallbackMailer.rescue_from_error = nil + CallbackMailer.after_deliver_instance = nil + CallbackMailer.around_deliver_instance = nil + CallbackMailer.abort_before_deliver = nil + CallbackMailer.around_handles_error = nil + end + + test "deliver_now should call after_deliver callback and can access sent message" do + mail_delivery = CallbackMailer.test_message + mail_delivery.deliver_now + + assert_kind_of CallbackMailer, CallbackMailer.after_deliver_instance + assert_not_empty CallbackMailer.after_deliver_instance.message.message_id + assert_equal mail_delivery.message_id, CallbackMailer.after_deliver_instance.message.message_id + assert_equal "test-receiver@test.com", CallbackMailer.after_deliver_instance.message.to.first + end + + test "deliver_now! should call after_deliver callback" do + CallbackMailer.test_message.deliver_now! + + assert_kind_of CallbackMailer, CallbackMailer.after_deliver_instance + end + + test "before_deliver can abort the delivery and not run after_deliver callbacks" do + CallbackMailer.abort_before_deliver = true + + mail_delivery = CallbackMailer.test_message + mail_delivery.deliver_now + + assert_nil mail_delivery.message_id + assert_nil CallbackMailer.after_deliver_instance + end + + test "deliver_later should call after_deliver callback and can access sent message" do + perform_enqueued_jobs do + silence_stream($stdout) do + CallbackMailer.test_message.deliver_later + end + end + assert_kind_of CallbackMailer, CallbackMailer.after_deliver_instance + assert_not_empty CallbackMailer.after_deliver_instance.message.message_id + end + + test "around_deliver is called after rescue_from on action processing exceptions" do + CallbackMailer.around_handles_error = true + + CallbackMailer.test_raise_action.deliver_now + assert CallbackMailer.rescue_from_error + end + + test "around_deliver is called before rescue_from on deliver! exceptions" do + CallbackMailer.around_handles_error = true + + stub_any_instance(Mail::TestMailer, instance: Mail::TestMailer.new({})) do |instance| + instance.stub(:deliver!, proc { raise "boom deliver exception" }) do + CallbackMailer.test_message.deliver_now + end + end + + assert_kind_of CallbackMailer, CallbackMailer.after_deliver_instance + assert_nil CallbackMailer.rescue_from_error + end +end diff --git a/actionmailer/test/delivery_methods_test.rb b/actionmailer/test/delivery_methods_test.rb index 08f84dbf3b40e..a39eb37eaa907 100644 --- a/actionmailer/test/delivery_methods_test.rb +++ b/actionmailer/test/delivery_methods_test.rb @@ -1,9 +1,17 @@ -require 'abstract_unit' -require 'mail' +# frozen_string_literal: true + +require "abstract_unit" class MyCustomDelivery end +class MyOptionedDelivery + attr_reader :options + def initialize(options) + @options = options + end +end + class BogusDelivery def initialize(*) end @@ -15,35 +23,37 @@ def deliver!(mail) class DefaultsDeliveryMethodsTest < ActiveSupport::TestCase test "default smtp settings" do - settings = { :address => "localhost", - :port => 25, - :domain => 'localhost.localdomain', - :user_name => nil, - :password => nil, - :authentication => nil, - :enable_starttls_auto => true } + settings = { address: "localhost", + port: 25, + domain: "localhost.localdomain", + user_name: nil, + password: nil, + authentication: nil, + enable_starttls_auto: true } assert_equal settings, ActionMailer::Base.smtp_settings end - test "default file delivery settings" do - settings = {:location => "#{Dir.tmpdir}/mails"} + test "default file delivery settings (with Rails.root)" do + settings = { location: "#{Rails.root}/tmp/mails" } assert_equal settings, ActionMailer::Base.file_settings end test "default sendmail settings" do - settings = {:location => '/usr/sbin/sendmail', - :arguments => '-i -t'} + settings = { + location: "/usr/sbin/sendmail", + arguments: %w[ -i ] + } assert_equal settings, ActionMailer::Base.sendmail_settings end end class CustomDeliveryMethodsTest < ActiveSupport::TestCase - def setup + setup do @old_delivery_method = ActionMailer::Base.delivery_method ActionMailer::Base.add_delivery_method :custom, MyCustomDelivery end - def teardown + teardown do ActionMailer::Base.delivery_method = @old_delivery_method new = ActionMailer::Base.delivery_methods.dup new.delete(:custom) @@ -56,8 +66,8 @@ def teardown end test "allow to customize custom settings" do - ActionMailer::Base.custom_settings = { :foo => :bar } - assert_equal Hash[:foo => :bar], ActionMailer::Base.custom_settings + ActionMailer::Base.custom_settings = { foo: :bar } + assert_equal Hash[foo: :bar], ActionMailer::Base.custom_settings end test "respond to custom settings" do @@ -75,98 +85,164 @@ def teardown class MailDeliveryTest < ActiveSupport::TestCase class DeliveryMailer < ActionMailer::Base DEFAULT_HEADERS = { - :to => 'mikel@test.lindsaar.net', - :from => 'jose@test.plataformatec.com' + to: "mikel@test.lindsaar.net", + from: "jose@test.plataformatec.com" } - def welcome(hash={}) + def welcome(hash = {}) mail(DEFAULT_HEADERS.merge(hash)) end end - def setup - ActionMailer::Base.delivery_method = :smtp + setup do + @old_delivery_method = DeliveryMailer.delivery_method end - def teardown - DeliveryMailer.delivery_method = :smtp - DeliveryMailer.perform_deliveries = true - DeliveryMailer.raise_delivery_errors = true + teardown do + DeliveryMailer.delivery_method = @old_delivery_method + DeliveryMailer.deliveries.clear end test "ActionMailer should be told when Mail gets delivered" do - DeliveryMailer.deliveries.clear - DeliveryMailer.expects(:deliver_mail).once - DeliveryMailer.welcome.deliver + DeliveryMailer.delivery_method = :test + assert_called(DeliveryMailer, :deliver_mail) do + DeliveryMailer.welcome.deliver_now + end end test "delivery method can be customized per instance" do - email = DeliveryMailer.welcome.deliver - assert_instance_of Mail::SMTP, email.delivery_method - email = DeliveryMailer.welcome(:delivery_method => :test).deliver - assert_instance_of Mail::TestMailer, email.delivery_method + stub_any_instance(Mail::SMTP, instance: Mail::SMTP.new({})) do |instance| + assert_called(instance, :deliver!) do + email = DeliveryMailer.welcome.deliver_now + assert_instance_of Mail::SMTP, email.delivery_method + email = DeliveryMailer.welcome(delivery_method: :test).deliver_now + assert_instance_of Mail::TestMailer, email.delivery_method + end + end end test "delivery method can be customized in subclasses not changing the parent" do DeliveryMailer.delivery_method = :test assert_equal :smtp, ActionMailer::Base.delivery_method - $BREAK = true - email = DeliveryMailer.welcome.deliver + email = DeliveryMailer.welcome.deliver_now assert_instance_of Mail::TestMailer, email.delivery_method end + test "delivery method options default to class level options" do + default_options = { a: "b" } + ActionMailer::Base.add_delivery_method :optioned, MyOptionedDelivery, default_options + mail_instance = DeliveryMailer.welcome(delivery_method: :optioned) + assert_equal default_options, mail_instance.delivery_method.options + end + + test "delivery method options can be overridden per mail instance" do + default_options = { a: "b" } + ActionMailer::Base.add_delivery_method :optioned, MyOptionedDelivery, default_options + overridden_options = { a: "a" } + mail_instance = DeliveryMailer.welcome(delivery_method: :optioned, delivery_method_options: overridden_options) + assert_equal overridden_options, mail_instance.delivery_method.options + end + + test "default delivery options can be overridden per mail instance" do + settings = { + address: "localhost", + port: 25, + domain: "localhost.localdomain", + user_name: nil, + password: nil, + authentication: nil, + enable_starttls_auto: true + } + assert_equal settings, ActionMailer::Base.smtp_settings + overridden_options = { user_name: "overridden", password: "somethingobtuse" } + mail_instance = DeliveryMailer.welcome(delivery_method_options: overridden_options) + delivery_method_instance = mail_instance.delivery_method + assert_equal "overridden", delivery_method_instance.settings[:user_name] + assert_equal "somethingobtuse", delivery_method_instance.settings[:password] + assert_equal delivery_method_instance.settings.merge(overridden_options), delivery_method_instance.settings + + # make sure that overriding delivery method options per mail instance doesn't affect the Base setting + assert_equal settings, ActionMailer::Base.smtp_settings + end + test "non registered delivery methods raises errors" do DeliveryMailer.delivery_method = :unknown - assert_raise RuntimeError do - DeliveryMailer.welcome.deliver + error = assert_raise RuntimeError do + DeliveryMailer.welcome.deliver_now end + assert_equal "Invalid delivery method :unknown", error.message + end + + test "undefined delivery methods raises errors" do + DeliveryMailer.delivery_method = nil + error = assert_raise RuntimeError do + DeliveryMailer.welcome.deliver_now + end + assert_equal "Delivery method cannot be nil", error.message end test "does not perform deliveries if requested" do - DeliveryMailer.perform_deliveries = false - DeliveryMailer.deliveries.clear - Mail::Message.any_instance.expects(:deliver!).never - DeliveryMailer.welcome.deliver + old_perform_deliveries = DeliveryMailer.perform_deliveries + begin + DeliveryMailer.perform_deliveries = false + stub_any_instance(Mail::Message) do |instance| + assert_not_called(instance, :deliver!) do + DeliveryMailer.welcome.deliver_now + end + end + ensure + DeliveryMailer.perform_deliveries = old_perform_deliveries + end end test "does not append the deliveries collection if told not to perform the delivery" do - DeliveryMailer.perform_deliveries = false - DeliveryMailer.deliveries.clear - DeliveryMailer.welcome.deliver - assert_equal(0, DeliveryMailer.deliveries.length) + old_perform_deliveries = DeliveryMailer.perform_deliveries + begin + DeliveryMailer.perform_deliveries = false + DeliveryMailer.welcome.deliver_now + assert_equal [], DeliveryMailer.deliveries + ensure + DeliveryMailer.perform_deliveries = old_perform_deliveries + end end test "raise errors on bogus deliveries" do DeliveryMailer.delivery_method = BogusDelivery - DeliveryMailer.deliveries.clear assert_raise RuntimeError do - DeliveryMailer.welcome.deliver + DeliveryMailer.welcome.deliver_now end end test "does not increment the deliveries collection on error" do DeliveryMailer.delivery_method = BogusDelivery - DeliveryMailer.deliveries.clear assert_raise RuntimeError do - DeliveryMailer.welcome.deliver + DeliveryMailer.welcome.deliver_now end - assert_equal(0, DeliveryMailer.deliveries.length) + assert_equal [], DeliveryMailer.deliveries end test "does not raise errors on bogus deliveries if set" do - DeliveryMailer.delivery_method = BogusDelivery - DeliveryMailer.raise_delivery_errors = false - assert_nothing_raised do - DeliveryMailer.welcome.deliver + old_raise_delivery_errors = DeliveryMailer.raise_delivery_errors + begin + DeliveryMailer.delivery_method = BogusDelivery + DeliveryMailer.raise_delivery_errors = false + assert_nothing_raised do + DeliveryMailer.welcome.deliver_now + end + ensure + DeliveryMailer.raise_delivery_errors = old_raise_delivery_errors end end test "does not increment the deliveries collection on bogus deliveries" do - DeliveryMailer.delivery_method = BogusDelivery - DeliveryMailer.raise_delivery_errors = false - DeliveryMailer.deliveries.clear - DeliveryMailer.welcome.deliver - assert_equal(0, DeliveryMailer.deliveries.length) + old_raise_delivery_errors = DeliveryMailer.raise_delivery_errors + begin + DeliveryMailer.delivery_method = BogusDelivery + DeliveryMailer.raise_delivery_errors = false + DeliveryMailer.welcome.deliver_now + assert_equal [], DeliveryMailer.deliveries + ensure + DeliveryMailer.raise_delivery_errors = old_raise_delivery_errors + end end - end diff --git a/actionmailer/test/fixtures/anonymous/welcome.erb b/actionmailer/test/fixtures/anonymous/welcome.erb new file mode 100644 index 0000000000000..8361da62c4e5d --- /dev/null +++ b/actionmailer/test/fixtures/anonymous/welcome.erb @@ -0,0 +1 @@ +Anonymous mailer body diff --git a/actionmailer/test/fixtures/attachments/foo.jpg b/actionmailer/test/fixtures/attachments/foo.jpg deleted file mode 100644 index b976fe5e002bf..0000000000000 Binary files a/actionmailer/test/fixtures/attachments/foo.jpg and /dev/null differ diff --git a/actionmailer/test/fixtures/attachments/test.jpg b/actionmailer/test/fixtures/attachments/test.jpg deleted file mode 100644 index b976fe5e002bf..0000000000000 Binary files a/actionmailer/test/fixtures/attachments/test.jpg and /dev/null differ diff --git a/actionpack/test/tmp/.gitignore b/actionmailer/test/fixtures/base_mailer/attachment_with_hash.html.erb similarity index 100% rename from actionpack/test/tmp/.gitignore rename to actionmailer/test/fixtures/base_mailer/attachment_with_hash.html.erb diff --git a/activerecord/test/migrations/empty/.gitkeep b/actionmailer/test/fixtures/base_mailer/attachment_with_hash_default_encoding.html.erb similarity index 100% rename from activerecord/test/migrations/empty/.gitkeep rename to actionmailer/test/fixtures/base_mailer/attachment_with_hash_default_encoding.html.erb diff --git a/actionmailer/test/fixtures/base_mailer/email_custom_layout.text.html.erb b/actionmailer/test/fixtures/base_mailer/email_custom_layout.text.html.erb deleted file mode 100644 index a2187308b6dfa..0000000000000 --- a/actionmailer/test/fixtures/base_mailer/email_custom_layout.text.html.erb +++ /dev/null @@ -1 +0,0 @@ -body_text \ No newline at end of file diff --git a/actionmailer/test/fixtures/base_mailer/email_with_translations.html.erb b/actionmailer/test/fixtures/base_mailer/email_with_translations.html.erb index 30466dd005391..d676a6d2da03d 100644 --- a/actionmailer/test/fixtures/base_mailer/email_with_translations.html.erb +++ b/actionmailer/test/fixtures/base_mailer/email_with_translations.html.erb @@ -1 +1 @@ -<%= t('.greet_user', :name => 'lifo') %> \ No newline at end of file +<%= t('.greet_user', name: 'lifo') %> \ No newline at end of file diff --git a/actionmailer/test/fixtures/base_mailer/implicit_multipart_formats.html.erb b/actionmailer/test/fixtures/base_mailer/implicit_multipart_formats.html.erb new file mode 100644 index 0000000000000..0179b070b8a65 --- /dev/null +++ b/actionmailer/test/fixtures/base_mailer/implicit_multipart_formats.html.erb @@ -0,0 +1 @@ +Implicit Multipart <%= formats.inspect %> \ No newline at end of file diff --git a/actionmailer/test/fixtures/base_mailer/implicit_multipart_formats.text.erb b/actionmailer/test/fixtures/base_mailer/implicit_multipart_formats.text.erb new file mode 100644 index 0000000000000..0179b070b8a65 --- /dev/null +++ b/actionmailer/test/fixtures/base_mailer/implicit_multipart_formats.text.erb @@ -0,0 +1 @@ +Implicit Multipart <%= formats.inspect %> \ No newline at end of file diff --git a/actionmailer/test/fixtures/base_mailer/implicit_with_locale.de-AT.text.erb b/actionmailer/test/fixtures/base_mailer/implicit_with_locale.de-AT.text.erb new file mode 100644 index 0000000000000..e97505fad9bad --- /dev/null +++ b/actionmailer/test/fixtures/base_mailer/implicit_with_locale.de-AT.text.erb @@ -0,0 +1 @@ +Implicit with locale DE-AT TEXT \ No newline at end of file diff --git a/actionmailer/test/fixtures/base_mailer/implicit_with_locale.de.html.erb b/actionmailer/test/fixtures/base_mailer/implicit_with_locale.de.html.erb new file mode 100644 index 0000000000000..0536b5d3e2b9f --- /dev/null +++ b/actionmailer/test/fixtures/base_mailer/implicit_with_locale.de.html.erb @@ -0,0 +1 @@ +Implicit with locale DE HTML \ No newline at end of file diff --git a/actionmailer/test/fixtures/base_mailer/inline_and_other_attachments.html.erb b/actionmailer/test/fixtures/base_mailer/inline_and_other_attachments.html.erb new file mode 100644 index 0000000000000..e20087812748f --- /dev/null +++ b/actionmailer/test/fixtures/base_mailer/inline_and_other_attachments.html.erb @@ -0,0 +1,5 @@ +

Inline Image

+ +<%= image_tag attachments['logo.png'].url %> + +

This is an image that is inline

\ No newline at end of file diff --git a/actionmailer/test/fixtures/base_mailer/inline_and_other_attachments.text.erb b/actionmailer/test/fixtures/base_mailer/inline_and_other_attachments.text.erb new file mode 100644 index 0000000000000..e161d244d27eb --- /dev/null +++ b/actionmailer/test/fixtures/base_mailer/inline_and_other_attachments.text.erb @@ -0,0 +1,4 @@ +Inline Image + +No image for you + diff --git a/railties/guides/code/getting_started/app/mailers/.gitkeep b/actionmailer/test/fixtures/base_mailer/welcome_with_headers.html.erb similarity index 100% rename from railties/guides/code/getting_started/app/mailers/.gitkeep rename to actionmailer/test/fixtures/base_mailer/welcome_with_headers.html.erb diff --git a/actionmailer/test/fixtures/base_mailer/without_mail_call.erb b/actionmailer/test/fixtures/base_mailer/without_mail_call.erb new file mode 100644 index 0000000000000..290379d5fb454 --- /dev/null +++ b/actionmailer/test/fixtures/base_mailer/without_mail_call.erb @@ -0,0 +1 @@ +<% raise 'the template should not be rendered' %> \ No newline at end of file diff --git a/railties/guides/code/getting_started/app/models/.gitkeep b/actionmailer/test/fixtures/base_test/after_action_mailer/welcome.html.erb similarity index 100% rename from railties/guides/code/getting_started/app/models/.gitkeep rename to actionmailer/test/fixtures/base_test/after_action_mailer/welcome.html.erb diff --git a/railties/guides/code/getting_started/lib/assets/.gitkeep b/actionmailer/test/fixtures/base_test/before_action_mailer/welcome.html.erb similarity index 100% rename from railties/guides/code/getting_started/lib/assets/.gitkeep rename to actionmailer/test/fixtures/base_test/before_action_mailer/welcome.html.erb diff --git a/railties/guides/code/getting_started/lib/tasks/.gitkeep b/actionmailer/test/fixtures/base_test/default_inline_attachment_mailer/welcome.html.erb similarity index 100% rename from railties/guides/code/getting_started/lib/tasks/.gitkeep rename to actionmailer/test/fixtures/base_test/default_inline_attachment_mailer/welcome.html.erb diff --git a/actionmailer/test/fixtures/base_test/late_inline_attachment_mailer/on_render.erb b/actionmailer/test/fixtures/base_test/late_inline_attachment_mailer/on_render.erb new file mode 100644 index 0000000000000..6decd3bb311cd --- /dev/null +++ b/actionmailer/test/fixtures/base_test/late_inline_attachment_mailer/on_render.erb @@ -0,0 +1,7 @@ +

Adding an inline image while rendering

+ +<% controller.attachments.inline["controller_attachments.jpg"] = 'via controller.attachments.inline' %> +<%= image_tag attachments['controller_attachments.jpg'].url %> + +<% attachments.inline["attachments.jpg"] = 'via attachments.inline' %> +<%= image_tag attachments['attachments.jpg'].url %> diff --git a/actionmailer/test/fixtures/caching_mailer/_partial.html.erb b/actionmailer/test/fixtures/caching_mailer/_partial.html.erb new file mode 100644 index 0000000000000..8e965f52b4712 --- /dev/null +++ b/actionmailer/test/fixtures/caching_mailer/_partial.html.erb @@ -0,0 +1,3 @@ +<% cache :caching do %> + Old fragment caching in a partial +<% end %> diff --git a/actionmailer/test/fixtures/caching_mailer/fragment_cache.html.erb b/actionmailer/test/fixtures/caching_mailer/fragment_cache.html.erb new file mode 100644 index 0000000000000..90189627daf2a --- /dev/null +++ b/actionmailer/test/fixtures/caching_mailer/fragment_cache.html.erb @@ -0,0 +1,3 @@ +<% cache :caching do %> +"Welcome" +<% end %> diff --git a/actionmailer/test/fixtures/caching_mailer/fragment_cache_in_partials.html.erb b/actionmailer/test/fixtures/caching_mailer/fragment_cache_in_partials.html.erb new file mode 100644 index 0000000000000..2957d083e8492 --- /dev/null +++ b/actionmailer/test/fixtures/caching_mailer/fragment_cache_in_partials.html.erb @@ -0,0 +1 @@ +<%= render "partial" %> diff --git a/actionmailer/test/fixtures/caching_mailer/fragment_caching_options.html.erb b/actionmailer/test/fixtures/caching_mailer/fragment_caching_options.html.erb new file mode 100644 index 0000000000000..0541ac321b7a1 --- /dev/null +++ b/actionmailer/test/fixtures/caching_mailer/fragment_caching_options.html.erb @@ -0,0 +1,3 @@ +<%= cache :no_digest, skip_digest: true, expires_in: 0 do %> + No Digest +<% end %> diff --git a/actionmailer/test/fixtures/caching_mailer/multipart_cache.html.erb b/actionmailer/test/fixtures/caching_mailer/multipart_cache.html.erb new file mode 100644 index 0000000000000..0d26baa2d7440 --- /dev/null +++ b/actionmailer/test/fixtures/caching_mailer/multipart_cache.html.erb @@ -0,0 +1,3 @@ +<% cache :html_caching, skip_digest: true do %> + "Welcome html" +<% end %> diff --git a/actionmailer/test/fixtures/caching_mailer/multipart_cache.text.erb b/actionmailer/test/fixtures/caching_mailer/multipart_cache.text.erb new file mode 100644 index 0000000000000..ef97326e03eff --- /dev/null +++ b/actionmailer/test/fixtures/caching_mailer/multipart_cache.text.erb @@ -0,0 +1,3 @@ +<% cache :text_caching, skip_digest: true do %> + "Welcome text" +<% end %> diff --git a/actionmailer/test/fixtures/caching_mailer/skip_fragment_cache_digesting.html.erb b/actionmailer/test/fixtures/caching_mailer/skip_fragment_cache_digesting.html.erb new file mode 100644 index 0000000000000..0d52429a81151 --- /dev/null +++ b/actionmailer/test/fixtures/caching_mailer/skip_fragment_cache_digesting.html.erb @@ -0,0 +1,3 @@ +<%= cache :no_digest, skip_digest: true do %> + No Digest +<% end %> diff --git a/actionmailer/test/fixtures/first_mailer/share.erb b/actionmailer/test/fixtures/first_mailer/share.erb deleted file mode 100644 index da43638ceb4ed..0000000000000 --- a/actionmailer/test/fixtures/first_mailer/share.erb +++ /dev/null @@ -1 +0,0 @@ -first mail diff --git a/actionmailer/test/fixtures/form_builder_mailer/welcome.html.erb b/actionmailer/test/fixtures/form_builder_mailer/welcome.html.erb new file mode 100644 index 0000000000000..180a827089c00 --- /dev/null +++ b/actionmailer/test/fixtures/form_builder_mailer/welcome.html.erb @@ -0,0 +1,3 @@ +<%= form_with(url: "/") do |f| %> + <%= f.message %> +<% end %> diff --git a/railties/guides/code/getting_started/test/fixtures/.gitkeep b/actionmailer/test/fixtures/mail_delivery_test/delivery_mailer/welcome.html.erb similarity index 100% rename from railties/guides/code/getting_started/test/fixtures/.gitkeep rename to actionmailer/test/fixtures/mail_delivery_test/delivery_mailer/welcome.html.erb diff --git a/actionmailer/test/fixtures/path.with.dots/funky_path_mailer/multipart_with_template_path_with_dots.erb b/actionmailer/test/fixtures/path.with.dots/funky_path_mailer/multipart_with_template_path_with_dots.erb deleted file mode 100644 index 2d0cd5c124c75..0000000000000 --- a/actionmailer/test/fixtures/path.with.dots/funky_path_mailer/multipart_with_template_path_with_dots.erb +++ /dev/null @@ -1 +0,0 @@ -Have some dots. Enjoy! \ No newline at end of file diff --git a/railties/guides/code/getting_started/test/functional/.gitkeep b/actionmailer/test/fixtures/proc_mailer/welcome.html.erb similarity index 100% rename from railties/guides/code/getting_started/test/functional/.gitkeep rename to actionmailer/test/fixtures/proc_mailer/welcome.html.erb diff --git a/actionmailer/test/fixtures/raw_email b/actionmailer/test/fixtures/raw_email deleted file mode 100644 index 43f7a59cee03f..0000000000000 --- a/actionmailer/test/fixtures/raw_email +++ /dev/null @@ -1,14 +0,0 @@ -From jamis_buck@byu.edu Mon May 2 16:07:05 2005 -Mime-Version: 1.0 (Apple Message framework v622) -Content-Transfer-Encoding: base64 -Message-Id: -Content-Type: text/plain; - charset=EUC-KR; - format=flowed -To: willard15georgina@jamis.backpackit.com -From: Jamis Buck -Subject: =?EUC-KR?Q?NOTE:_=C7=D1=B1=B9=B8=BB=B7=CE_=C7=CF=B4=C2_=B0=CD?= -Date: Mon, 2 May 2005 16:07:05 -0600 - -tOu6zrrQwMcguLbC+bChwfa3ziwgv+y4rrTCIMfPs6q01MC7ILnPvcC0z7TZLg0KDQrBpiDAzLin -wLogSmFtaXPA1LTPtNku diff --git a/actionmailer/test/fixtures/raw_email10 b/actionmailer/test/fixtures/raw_email10 deleted file mode 100644 index edad5ccff1ca8..0000000000000 --- a/actionmailer/test/fixtures/raw_email10 +++ /dev/null @@ -1,20 +0,0 @@ -Return-Path: -Received: from xxx.xxxx.xxx by xxx.xxxx.xxx with ESMTP id C1B953B4CB6 for ; Tue, 10 May 2005 15:27:05 -0500 -Received: from SMS-GTYxxx.xxxx.xxx by xxx.xxxx.xxx with ESMTP id ca for ; Tue, 10 May 2005 15:27:04 -0500 -Received: from xxx.xxxx.xxx by SMS-GTYxxx.xxxx.xxx with ESMTP id j4AKR3r23323 for ; Tue, 10 May 2005 15:27:03 -0500 -Date: Tue, 10 May 2005 15:27:03 -0500 -From: xxx@xxxx.xxx -Sender: xxx@xxxx.xxx -To: xxxxxxxxxxx@xxxx.xxxx.xxx -Message-Id: -X-Original-To: xxxxxxxxxxx@xxxx.xxxx.xxx -Delivered-To: xxx@xxxx.xxx -Importance: normal -Content-Type: text/plain; charset=X-UNKNOWN - -Test test. Hi. Waving. m - ----------------------------------------------------------------- -Sent via Bell Mobility's Text Messaging service. -Envoyé par le service de messagerie texte de Bell Mobilité. ----------------------------------------------------------------- diff --git a/actionmailer/test/fixtures/raw_email12 b/actionmailer/test/fixtures/raw_email12 deleted file mode 100644 index 2cd31720d3b61..0000000000000 --- a/actionmailer/test/fixtures/raw_email12 +++ /dev/null @@ -1,32 +0,0 @@ -Mime-Version: 1.0 (Apple Message framework v730) -Content-Type: multipart/mixed; boundary=Apple-Mail-13-196941151 -Message-Id: <9169D984-4E0B-45EF-82D4-8F5E53AD7012@example.com> -From: foo@example.com -Subject: testing -Date: Mon, 6 Jun 2005 22:21:22 +0200 -To: blah@example.com - - ---Apple-Mail-13-196941151 -Content-Transfer-Encoding: quoted-printable -Content-Type: text/plain; - charset=ISO-8859-1; - delsp=yes; - format=flowed - -This is the first part. - ---Apple-Mail-13-196941151 -Content-Type: image/jpeg -Content-Transfer-Encoding: base64 -Content-Location: Photo25.jpg -Content-ID: -Content-Disposition: inline - -jamisSqGSIb3DQEHAqCAMIjamisxCzAJBgUrDgMCGgUAMIAGCSqGSjamisEHAQAAoIIFSjCCBUYw -ggQujamisQICBD++ukQwDQYJKojamisNAQEFBQAwMTELMAkGA1UEBhMCRjamisAKBgNVBAoTA1RE -QzEUMBIGjamisxMLVERDIE9DRVMgQ0jamisNMDQwMjI5MTE1OTAxWhcNMDYwMjamisIyOTAxWjCB -gDELMAkGA1UEjamisEsxKTAnBgNVBAoTIEjamisuIG9yZ2FuaXNhdG9yaXNrIHRpbjamisRuaW5= - ---Apple-Mail-13-196941151-- - diff --git a/actionmailer/test/fixtures/raw_email13 b/actionmailer/test/fixtures/raw_email13 deleted file mode 100644 index 7d9314e36a75d..0000000000000 --- a/actionmailer/test/fixtures/raw_email13 +++ /dev/null @@ -1,29 +0,0 @@ -Mime-Version: 1.0 (Apple Message framework v730) -Content-Type: multipart/mixed; boundary=Apple-Mail-13-196941151 -Message-Id: <9169D984-4E0B-45EF-82D4-8F5E53AD7012@example.com> -From: foo@example.com -Subject: testing -Date: Mon, 6 Jun 2005 22:21:22 +0200 -To: blah@example.com - - ---Apple-Mail-13-196941151 -Content-Transfer-Encoding: quoted-printable -Content-Type: text/plain; - charset=ISO-8859-1; - delsp=yes; - format=flowed - -This is the first part. - ---Apple-Mail-13-196941151 -Content-Type: text/x-ruby-script; name="hello.rb" -Content-Transfer-Encoding: 7bit -Content-Disposition: attachment; - filename="api.rb" - -puts "Hello, world!" -gets - ---Apple-Mail-13-196941151-- - diff --git a/actionmailer/test/fixtures/raw_email2 b/actionmailer/test/fixtures/raw_email2 deleted file mode 100644 index 9f87bb2a98cf1..0000000000000 --- a/actionmailer/test/fixtures/raw_email2 +++ /dev/null @@ -1,114 +0,0 @@ -From xxxxxxxxx.xxxxxxx@gmail.com Sun May 8 19:07:09 2005 -Return-Path: -X-Original-To: xxxxx@xxxxx.xxxxxxxxx.com -Delivered-To: xxxxx@xxxxx.xxxxxxxxx.com -Received: from localhost (localhost [127.0.0.1]) - by xxxxx.xxxxxxxxx.com (Postfix) with ESMTP id 06C9DA98D - for ; Sun, 8 May 2005 19:09:13 +0000 (GMT) -Received: from xxxxx.xxxxxxxxx.com ([127.0.0.1]) - by localhost (xxxxx.xxxxxxxxx.com [127.0.0.1]) (amavisd-new, port 10024) - with LMTP id 88783-08 for ; - Sun, 8 May 2005 19:09:12 +0000 (GMT) -Received: from xxxxxxx.xxxxxxxxx.com (xxxxxxx.xxxxxxxxx.com [69.36.39.150]) - by xxxxx.xxxxxxxxx.com (Postfix) with ESMTP id 10D8BA960 - for ; Sun, 8 May 2005 19:09:12 +0000 (GMT) -Received: from zproxy.gmail.com (zproxy.gmail.com [64.233.162.199]) - by xxxxxxx.xxxxxxxxx.com (Postfix) with ESMTP id 9EBC4148EAB - for ; Sun, 8 May 2005 14:09:11 -0500 (CDT) -Received: by zproxy.gmail.com with SMTP id 13so1233405nzp - for ; Sun, 08 May 2005 12:09:11 -0700 (PDT) -DomainKey-Signature: a=rsa-sha1; q=dns; c=nofws; - s=beta; d=gmail.com; - h=received:message-id:date:from:reply-to:to:subject:in-reply-to:mime-version:content-type:references; - b=cid1mzGEFa3gtRa06oSrrEYfKca2CTKu9sLMkWxjbvCsWMtp9RGEILjUz0L5RySdH5iO661LyNUoHRFQIa57bylAbXM3g2DTEIIKmuASDG3x3rIQ4sHAKpNxP7Pul+mgTaOKBv+spcH7af++QEJ36gHFXD2O/kx9RePs3JNf/K8= -Received: by 10.36.10.16 with SMTP id 16mr1012493nzj; - Sun, 08 May 2005 12:09:11 -0700 (PDT) -Received: by 10.36.5.10 with HTTP; Sun, 8 May 2005 12:09:11 -0700 (PDT) -Message-ID: -Date: Sun, 8 May 2005 14:09:11 -0500 -From: xxxxxxxxx xxxxxxx -Reply-To: xxxxxxxxx xxxxxxx -To: xxxxx xxxx -Subject: Fwd: Signed email causes file attachments -In-Reply-To: -Mime-Version: 1.0 -Content-Type: multipart/mixed; - boundary="----=_Part_5028_7368284.1115579351471" -References: - -------=_Part_5028_7368284.1115579351471 -Content-Type: text/plain; charset=ISO-8859-1 -Content-Transfer-Encoding: quoted-printable -Content-Disposition: inline - -We should not include these files or vcards as attachments. - ----------- Forwarded message ---------- -From: xxxxx xxxxxx -Date: May 8, 2005 1:17 PM -Subject: Signed email causes file attachments -To: xxxxxxx@xxxxxxxxxx.com - - -Hi, - -Just started to use my xxxxxxxx account (to set-up a GTD system, -natch) and noticed that when I send content via email the signature/ -certificate from my email account gets added as a file (e.g. -"smime.p7s"). - -Obviously I can uncheck the signature option in the Mail compose -window but how often will I remember to do that? - -Is there any way these kind of files could be ignored, e.g. via some -sort of exclusions list? - -------=_Part_5028_7368284.1115579351471 -Content-Type: application/pkcs7-signature; name=smime.p7s -Content-Transfer-Encoding: base64 -Content-Disposition: attachment; filename="smime.p7s" - -MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIGFDCCAs0w -ggI2oAMCAQICAw5c+TANBgkqhkiG9w0BAQQFADBiMQswCQYDVQQGEwJaQTElMCMGA1UEChMcVGhh -d3RlIENvbnN1bHRpbmcgKFB0eSkgTHRkLjEsMCoGA1UEAxMjVGhhd3RlIFBlcnNvbmFsIEZyZWVt -YWlsIElzc3VpbmcgQ0EwHhcNMDUwMzI5MDkzOTEwWhcNMDYwMzI5MDkzOTEwWjBCMR8wHQYDVQQD -ExZUaGF3dGUgRnJlZW1haWwgTWVtYmVyMR8wHQYJKoZIhvcNAQkBFhBzbWhhdW5jaEBtYWMuY29t -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAn90dPsYS3LjfMY211OSYrDQLzwNYPlAL -7+/0XA+kdy8/rRnyEHFGwhNCDmg0B6pxC7z3xxJD/8GfCd+IYUUNUQV5m9MkxfP9pTVXZVIYLaBw -o8xS3A0a1LXealcmlEbJibmKkEaoXci3MhryLgpaa+Kk/sH02SNatDO1vS28bPsibZpcc6deFrla -hSYnL+PW54mDTGHIcCN2fbx/Y6qspzqmtKaXrv75NBtuy9cB6KzU4j2xXbTkAwz3pRSghJJaAwdp -+yIivAD3vr0kJE3p+Ez34HMh33EXEpFoWcN+MCEQZD9WnmFViMrvfvMXLGVFQfAAcC060eGFSRJ1 -ZQ9UVQIDAQABoy0wKzAbBgNVHREEFDASgRBzbWhhdW5jaEBtYWMuY29tMAwGA1UdEwEB/wQCMAAw -DQYJKoZIhvcNAQEEBQADgYEAQMrg1n2pXVWteP7BBj+Pk3UfYtbuHb42uHcLJjfjnRlH7AxnSwrd -L3HED205w3Cq8T7tzVxIjRRLO/ljq0GedSCFBky7eYo1PrXhztGHCTSBhsiWdiyLWxKlOxGAwJc/ -lMMnwqLOdrQcoF/YgbjeaUFOQbUh94w9VDNpWZYCZwcwggM/MIICqKADAgECAgENMA0GCSqGSIb3 -DQEBBQUAMIHRMQswCQYDVQQGEwJaQTEVMBMGA1UECBMMV2VzdGVybiBDYXBlMRIwEAYDVQQHEwlD -YXBlIFRvd24xGjAYBgNVBAoTEVRoYXd0ZSBDb25zdWx0aW5nMSgwJgYDVQQLEx9DZXJ0aWZpY2F0 -aW9uIFNlcnZpY2VzIERpdmlzaW9uMSQwIgYDVQQDExtUaGF3dGUgUGVyc29uYWwgRnJlZW1haWwg -Q0ExKzApBgkqhkiG9w0BCQEWHHBlcnNvbmFsLWZyZWVtYWlsQHRoYXd0ZS5jb20wHhcNMDMwNzE3 -MDAwMDAwWhcNMTMwNzE2MjM1OTU5WjBiMQswCQYDVQQGEwJaQTElMCMGA1UEChMcVGhhd3RlIENv -bnN1bHRpbmcgKFB0eSkgTHRkLjEsMCoGA1UEAxMjVGhhd3RlIFBlcnNvbmFsIEZyZWVtYWlsIElz -c3VpbmcgQ0EwgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAMSmPFVzVftOucqZWh5owHUEcJ3f -6f+jHuy9zfVb8hp2vX8MOmHyv1HOAdTlUAow1wJjWiyJFXCO3cnwK4Vaqj9xVsuvPAsH5/EfkTYk -KhPPK9Xzgnc9A74r/rsYPge/QIACZNenprufZdHFKlSFD0gEf6e20TxhBEAeZBlyYLf7AgMBAAGj -gZQwgZEwEgYDVR0TAQH/BAgwBgEB/wIBADBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLnRo -YXd0ZS5jb20vVGhhd3RlUGVyc29uYWxGcmVlbWFpbENBLmNybDALBgNVHQ8EBAMCAQYwKQYDVR0R -BCIwIKQeMBwxGjAYBgNVBAMTEVByaXZhdGVMYWJlbDItMTM4MA0GCSqGSIb3DQEBBQUAA4GBAEiM -0VCD6gsuzA2jZqxnD3+vrL7CF6FDlpSdf0whuPg2H6otnzYvwPQcUCCTcDz9reFhYsPZOhl+hLGZ -GwDFGguCdJ4lUJRix9sncVcljd2pnDmOjCBPZV+V2vf3h9bGCE6u9uo05RAaWzVNd+NWIXiC3CEZ -Nd4ksdMdRv9dX2VPMYIC5zCCAuMCAQEwaTBiMQswCQYDVQQGEwJaQTElMCMGA1UEChMcVGhhd3Rl -IENvbnN1bHRpbmcgKFB0eSkgTHRkLjEsMCoGA1UEAxMjVGhhd3RlIFBlcnNvbmFsIEZyZWVtYWls -IElzc3VpbmcgQ0ECAw5c+TAJBgUrDgMCGgUAoIIBUzAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcB -MBwGCSqGSIb3DQEJBTEPFw0wNTA1MDgxODE3NDZaMCMGCSqGSIb3DQEJBDEWBBQSkG9j6+hB0pKp -fV9tCi/iP59sNTB4BgkrBgEEAYI3EAQxazBpMGIxCzAJBgNVBAYTAlpBMSUwIwYDVQQKExxUaGF3 -dGUgQ29uc3VsdGluZyAoUHR5KSBMdGQuMSwwKgYDVQQDEyNUaGF3dGUgUGVyc29uYWwgRnJlZW1h -aWwgSXNzdWluZyBDQQIDDlz5MHoGCyqGSIb3DQEJEAILMWugaTBiMQswCQYDVQQGEwJaQTElMCMG -A1UEChMcVGhhd3RlIENvbnN1bHRpbmcgKFB0eSkgTHRkLjEsMCoGA1UEAxMjVGhhd3RlIFBlcnNv -bmFsIEZyZWVtYWlsIElzc3VpbmcgQ0ECAw5c+TANBgkqhkiG9w0BAQEFAASCAQAm1GeF7dWfMvrW -8yMPjkhE+R8D1DsiCoWSCp+5gAQm7lcK7V3KrZh5howfpI3TmCZUbbaMxOH+7aKRKpFemxoBY5Q8 -rnCkbpg/++/+MI01T69hF/rgMmrGcrv2fIYy8EaARLG0xUVFSZHSP+NQSYz0TTmh4cAESHMzY3JA -nHOoUkuPyl8RXrimY1zn0lceMXlweZRouiPGuPNl1hQKw8P+GhOC5oLlM71UtStnrlk3P9gqX5v7 -Tj7Hx057oVfY8FMevjxGwU3EK5TczHezHbWWgTyum9l2ZQbUQsDJxSniD3BM46C1VcbDLPaotAZ0 -fTYLZizQfm5hcWEbfYVzkSzLAAAAAAAA -------=_Part_5028_7368284.1115579351471-- - diff --git a/actionmailer/test/fixtures/raw_email3 b/actionmailer/test/fixtures/raw_email3 deleted file mode 100644 index 3a0927490ae1f..0000000000000 --- a/actionmailer/test/fixtures/raw_email3 +++ /dev/null @@ -1,70 +0,0 @@ -From xxxx@xxxx.com Tue May 10 11:28:07 2005 -Return-Path: -X-Original-To: xxxx@xxxx.com -Delivered-To: xxxx@xxxx.com -Received: from localhost (localhost [127.0.0.1]) - by xxx.xxxxx.com (Postfix) with ESMTP id 50FD3A96F - for ; Tue, 10 May 2005 17:26:50 +0000 (GMT) -Received: from xxx.xxxxx.com ([127.0.0.1]) - by localhost (xxx.xxxxx.com [127.0.0.1]) (amavisd-new, port 10024) - with LMTP id 70060-03 for ; - Tue, 10 May 2005 17:26:49 +0000 (GMT) -Received: from xxx.xxxxx.com (xxx.xxxxx.com [69.36.39.150]) - by xxx.xxxxx.com (Postfix) with ESMTP id 8B957A94B - for ; Tue, 10 May 2005 17:26:48 +0000 (GMT) -Received: from xxx.xxxxx.com (xxx.xxxxx.com [64.233.184.203]) - by xxx.xxxxx.com (Postfix) with ESMTP id 9972514824C - for ; Tue, 10 May 2005 12:26:40 -0500 (CDT) -Received: by xxx.xxxxx.com with SMTP id 68so1694448wri - for ; Tue, 10 May 2005 10:26:40 -0700 (PDT) -DomainKey-Signature: a=rsa-sha1; q=dns; c=nofws; - s=beta; d=xxxxx.com; - h=received:message-id:date:from:reply-to:to:subject:mime-version:content-type; - b=g8ZO5ttS6GPEMAz9WxrRk9+9IXBUfQIYsZLL6T88+ECbsXqGIgfGtzJJFn6o9CE3/HMrrIGkN5AisxVFTGXWxWci5YA/7PTVWwPOhJff5BRYQDVNgRKqMl/SMttNrrRElsGJjnD1UyQ/5kQmcBxq2PuZI5Zc47u6CILcuoBcM+A= -Received: by 10.54.96.19 with SMTP id t19mr621017wrb; - Tue, 10 May 2005 10:26:39 -0700 (PDT) -Received: by 10.54.110.5 with HTTP; Tue, 10 May 2005 10:26:39 -0700 (PDT) -Message-ID: -Date: Tue, 10 May 2005 11:26:39 -0600 -From: Test Tester -Reply-To: Test Tester -To: xxxx@xxxx.com, xxxx@xxxx.com -Subject: Another PDF -Mime-Version: 1.0 -Content-Type: multipart/mixed; - boundary="----=_Part_2192_32400445.1115745999735" -X-Virus-Scanned: amavisd-new at textdrive.com - -------=_Part_2192_32400445.1115745999735 -Content-Type: text/plain; charset=ISO-8859-1 -Content-Transfer-Encoding: quoted-printable -Content-Disposition: inline - -Just attaching another PDF, here, to see what the message looks like, -and to see if I can figure out what is going wrong here. - -------=_Part_2192_32400445.1115745999735 -Content-Type: application/pdf; name="broken.pdf" -Content-Transfer-Encoding: base64 -Content-Disposition: attachment; filename="broken.pdf" - -JVBERi0xLjQNCiXk9tzfDQoxIDAgb2JqDQo8PCAvTGVuZ3RoIDIgMCBSDQogICAvRmlsdGVyIC9G -bGF0ZURlY29kZQ0KPj4NCnN0cmVhbQ0KeJy9Wt2KJbkNvm/od6jrhZxYln9hWEh2p+8HBvICySaE -ycLuTV4/1ifJ9qnq09NpSBimu76yLUuy/qzqcPz7+em3Ixx/CDc6CsXxs3b5+fvfjr/8cPz6/BRu -rbfAx/n3739/fuJylJ5u5fjX81OuDr4deK4Bz3z/aDP+8fz0yw8g0Ofq7ktr1Mn+u28rvhy/jVeD -QSa+9YNKHP/pxjvDNfVAx/m3MFz54FhvTbaseaxiDoN2LeMVMw+yA7RbHSCDzxZuaYB2E1Yay7QU -x89vz0+tyFDKMlAHK5yqLmnjF+c4RjEiQIUeKwblXMe+AsZjN1J5yGQL5DHpDHksurM81rF6PKab -gK6zAarIDzIiUY23rJsN9iorAE816aIu6lsgAdQFsuhhkHOUFgVjp2GjMqSewITXNQ27jrMeamkg -1rPI3iLWG2CIaSBB+V1245YVRICGbbpYKHc2USFDl6M09acQVQYhlwIrkBNLISvXhGlF1wi5FHCw -wxZkoGNJlVeJCEsqKA+3YAV5AMb6KkeaqEJQmFKKQU8T1pRi2ihE1Y4CDrqoYFFXYjJJOatsyzuI -8SIlykuxKTMibWK8H1PgEvqYgs4GmQSrEjJAalgGirIhik+p4ZQN9E3ETFPAHE1b8pp1l/0Rc1gl -fQs0ABWvyoZZzU8VnPXwVVcO9BEsyjEJaO6eBoZRyKGlrKoYoOygA8BGIzgwN3RQ15ouigG5idZQ -fx2U4Db2CqiLO0WHAZoylGiCAqhniNQjFjQPSkmjwfNTgQ6M1Ih+eWo36wFmjIxDJZiGUBiWsAyR -xX3EekGOizkGI96Ol9zVZTAivikURhRsHh2E3JhWMpSTZCnnonrLhMCodgrNcgo4uyJUJc6qnVss -nrGd1Ptr0YwisCOYyIbUwVjV4xBUNLbguSO2YHujonAMJkMdSI7bIw91Akq2AUlMUWGFTMAOamjU -OvZQCxIkY2pCpMFo/IwLdVLHs6nddwTRrgoVbvLU9eB0G4EMndV0TNoxHbt3JBWwK6hhv3iHfDtF -yokB302IpEBTnWICde4uYc/1khDbSIkQopO6lcqamGBu1OSE3N5IPSsZX00CkSHRiiyx6HQIShsS -HSVNswdVsaOUSAWq9aYhDtGDaoG5a3lBGkYt/lFlBFt1UqrYnzVtUpUQnLiZeouKgf1KhRBViRRk -ExepJCzTwEmFDalIRbLEGtw0gfpESOpIAF/NnpPzcVCG86s0g2DuSyd41uhNGbEgaSrWEXORErbw -------=_Part_2192_32400445.1115745999735-- - diff --git a/actionmailer/test/fixtures/raw_email4 b/actionmailer/test/fixtures/raw_email4 deleted file mode 100644 index 639ad40e495f0..0000000000000 --- a/actionmailer/test/fixtures/raw_email4 +++ /dev/null @@ -1,59 +0,0 @@ -Return-Path: -Received: from xxx.xxxx.xxx by xxx.xxxx.xxx with ESMTP id 6AAEE3B4D23 for ; Sun, 8 May 2005 12:30:23 -0500 -Received: from xxx.xxxx.xxx by xxx.xxxx.xxx with ESMTP id j48HUC213279 for ; Sun, 8 May 2005 12:30:13 -0500 -Received: from conversion-xxx.xxxx.xxx.net by xxx.xxxx.xxx id <0IG600901LQ64I@xxx.xxxx.xxx> for ; Sun, 8 May 2005 12:30:12 -0500 -Received: from agw1 by xxx.xxxx.xxx with ESMTP id <0IG600JFYLYCAxxx@xxxx.xxx> for ; Sun, 8 May 2005 12:30:12 -0500 -Date: Sun, 8 May 2005 12:30:08 -0500 -From: xxx@xxxx.xxx -To: xxx@xxxx.xxx -Message-Id: <7864245.1115573412626.JavaMxxx@xxxx.xxx> -Subject: Filth -Mime-Version: 1.0 -Content-Type: multipart/mixed; boundary=mimepart_427e4cb4ca329_133ae40413c81ef -X-Mms-Priority: 1 -X-Mms-Transaction-Id: 3198421808-0 -X-Mms-Message-Type: 0 -X-Mms-Sender-Visibility: 1 -X-Mms-Read-Reply: 1 -X-Original-To: xxx@xxxx.xxx -X-Mms-Message-Class: 0 -X-Mms-Delivery-Report: 0 -X-Mms-Mms-Version: 16 -Delivered-To: xxx@xxxx.xxx -X-Nokia-Ag-Version: 2.0 - -This is a multi-part message in MIME format. - ---mimepart_427e4cb4ca329_133ae40413c81ef -Content-Type: multipart/mixed; boundary=mimepart_427e4cb4cbd97_133ae40413c8217 - - - ---mimepart_427e4cb4cbd97_133ae40413c8217 -Content-Type: text/plain; charset=utf-8 -Content-Transfer-Encoding: 7bit -Content-Disposition: inline -Content-Location: text.txt - -Some text - ---mimepart_427e4cb4cbd97_133ae40413c8217-- - ---mimepart_427e4cb4ca329_133ae40413c81ef -Content-Type: text/plain; charset=us-ascii -Content-Transfer-Encoding: 7bit - - --- -This Orange Multi Media Message was sent wirefree from an Orange -MMS phone. If you would like to reply, please text or phone the -sender directly by using the phone number listed in the sender's -address. To learn more about Orange's Multi Media Messaging -Service, find us on the Web at xxx.xxxx.xxx.uk/mms - - ---mimepart_427e4cb4ca329_133ae40413c81ef - - ---mimepart_427e4cb4ca329_133ae40413c81ef- - diff --git a/actionmailer/test/fixtures/raw_email5 b/actionmailer/test/fixtures/raw_email5 deleted file mode 100644 index bbe31bcdc5ef7..0000000000000 --- a/actionmailer/test/fixtures/raw_email5 +++ /dev/null @@ -1,19 +0,0 @@ -Return-Path: -Received: from xxx.xxxx.xxx by xxx.xxxx.xxx with ESMTP id C1B953B4CB6 for ; Tue, 10 May 2005 15:27:05 -0500 -Received: from SMS-GTYxxx.xxxx.xxx by xxx.xxxx.xxx with ESMTP id ca for ; Tue, 10 May 2005 15:27:04 -0500 -Received: from xxx.xxxx.xxx by SMS-GTYxxx.xxxx.xxx with ESMTP id j4AKR3r23323 for ; Tue, 10 May 2005 15:27:03 -0500 -Date: Tue, 10 May 2005 15:27:03 -0500 -From: xxx@xxxx.xxx -Sender: xxx@xxxx.xxx -To: xxxxxxxxxxx@xxxx.xxxx.xxx -Message-Id: -X-Original-To: xxxxxxxxxxx@xxxx.xxxx.xxx -Delivered-To: xxx@xxxx.xxx -Importance: normal - -Test test. Hi. Waving. m - ----------------------------------------------------------------- -Sent via Bell Mobility's Text Messaging service. -Envoyé par le service de messagerie texte de Bell Mobilité. ----------------------------------------------------------------- diff --git a/actionmailer/test/fixtures/raw_email6 b/actionmailer/test/fixtures/raw_email6 deleted file mode 100644 index 8e37bd73921a5..0000000000000 --- a/actionmailer/test/fixtures/raw_email6 +++ /dev/null @@ -1,20 +0,0 @@ -Return-Path: -Received: from xxx.xxxx.xxx by xxx.xxxx.xxx with ESMTP id C1B953B4CB6 for ; Tue, 10 May 2005 15:27:05 -0500 -Received: from SMS-GTYxxx.xxxx.xxx by xxx.xxxx.xxx with ESMTP id ca for ; Tue, 10 May 2005 15:27:04 -0500 -Received: from xxx.xxxx.xxx by SMS-GTYxxx.xxxx.xxx with ESMTP id j4AKR3r23323 for ; Tue, 10 May 2005 15:27:03 -0500 -Date: Tue, 10 May 2005 15:27:03 -0500 -From: xxx@xxxx.xxx -Sender: xxx@xxxx.xxx -To: xxxxxxxxxxx@xxxx.xxxx.xxx -Message-Id: -X-Original-To: xxxxxxxxxxx@xxxx.xxxx.xxx -Delivered-To: xxx@xxxx.xxx -Importance: normal -Content-Type: text/plain; charset=us-ascii - -Test test. Hi. Waving. m - ----------------------------------------------------------------- -Sent via Bell Mobility's Text Messaging service. -Envoyé par le service de messagerie texte de Bell Mobilité. ----------------------------------------------------------------- diff --git a/actionmailer/test/fixtures/raw_email7 b/actionmailer/test/fixtures/raw_email7 deleted file mode 100644 index da64ada8a50cf..0000000000000 --- a/actionmailer/test/fixtures/raw_email7 +++ /dev/null @@ -1,66 +0,0 @@ -Mime-Version: 1.0 (Apple Message framework v730) -Content-Type: multipart/mixed; boundary=Apple-Mail-13-196941151 -Message-Id: <9169D984-4E0B-45EF-82D4-8F5E53AD7012@example.com> -From: foo@example.com -Subject: testing -Date: Mon, 6 Jun 2005 22:21:22 +0200 -To: blah@example.com - - ---Apple-Mail-13-196941151 -Content-Type: multipart/mixed; - boundary=Apple-Mail-12-196940926 - - ---Apple-Mail-12-196940926 -Content-Transfer-Encoding: quoted-printable -Content-Type: text/plain; - charset=ISO-8859-1; - delsp=yes; - format=flowed - -This is the first part. - ---Apple-Mail-12-196940926 -Content-Transfer-Encoding: 7bit -Content-Type: text/x-ruby-script; - x-unix-mode=0666; - name="test.rb" -Content-Disposition: attachment; - filename=test.rb - -puts "testing, testing" - ---Apple-Mail-12-196940926 -Content-Transfer-Encoding: base64 -Content-Type: application/pdf; - x-unix-mode=0666; - name="test.pdf" -Content-Disposition: inline; - filename=test.pdf - -YmxhaCBibGFoIGJsYWg= - ---Apple-Mail-12-196940926 -Content-Transfer-Encoding: 7bit -Content-Type: text/plain; - charset=US-ASCII; - format=flowed - - - ---Apple-Mail-12-196940926-- - ---Apple-Mail-13-196941151 -Content-Transfer-Encoding: base64 -Content-Type: application/pkcs7-signature; - name=smime.p7s -Content-Disposition: attachment; - filename=smime.p7s - -jamisSqGSIb3DQEHAqCAMIjamisxCzAJBgUrDgMCGgUAMIAGCSqGSjamisEHAQAAoIIFSjCCBUYw -ggQujamisQICBD++ukQwDQYJKojamisNAQEFBQAwMTELMAkGA1UEBhMCRjamisAKBgNVBAoTA1RE -QzEUMBIGjamisxMLVERDIE9DRVMgQ0jamisNMDQwMjI5MTE1OTAxWhcNMDYwMjamisIyOTAxWjCB -gDELMAkGA1UEjamisEsxKTAnBgNVBAoTIEjamisuIG9yZ2FuaXNhdG9yaXNrIHRpbjamisRuaW5= - ---Apple-Mail-13-196941151-- diff --git a/actionmailer/test/fixtures/raw_email8 b/actionmailer/test/fixtures/raw_email8 deleted file mode 100644 index 79996365b3d0e..0000000000000 --- a/actionmailer/test/fixtures/raw_email8 +++ /dev/null @@ -1,47 +0,0 @@ -From xxxxxxxxx.xxxxxxx@gmail.com Sun May 8 19:07:09 2005 -Return-Path: -Message-ID: -Date: Sun, 8 May 2005 14:09:11 -0500 -From: xxxxxxxxx xxxxxxx -Reply-To: xxxxxxxxx xxxxxxx -To: xxxxx xxxx -Subject: Fwd: Signed email causes file attachments -In-Reply-To: -Mime-Version: 1.0 -Content-Type: multipart/mixed; - boundary="----=_Part_5028_7368284.1115579351471" -References: - -------=_Part_5028_7368284.1115579351471 -Content-Type: text/plain; charset=ISO-8859-1 -Content-Transfer-Encoding: quoted-printable -Content-Disposition: inline - -We should not include these files or vcards as attachments. - ----------- Forwarded message ---------- -From: xxxxx xxxxxx -Date: May 8, 2005 1:17 PM -Subject: Signed email causes file attachments -To: xxxxxxx@xxxxxxxxxx.com - - -Hi, - -Test attachments oddly encoded with japanese charset. - - -------=_Part_5028_7368284.1115579351471 -Content-Type: application/octet-stream; name*=iso-2022-jp'ja'01%20Quien%20Te%20Dij%8aat.%20Pitbull.mp3 -Content-Transfer-Encoding: base64 -Content-Disposition: attachment - -MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIGFDCCAs0w -ggI2oAMCAQICAw5c+TANBgkqhkiG9w0BAQQFADBiMQswCQYDVQQGEwJaQTElMCMGA1UEChMcVGhh -d3RlIENvbnN1bHRpbmcgKFB0eSkgTHRkLjEsMCoGA1UEAxMjVGhhd3RlIFBlcnNvbmFsIEZyZWVt -YWlsIElzc3VpbmcgQ0EwHhcNMDUwMzI5MDkzOTEwWhcNMDYwMzI5MDkzOTEwWjBCMR8wHQYDVQQD -ExZUaGF3dGUgRnJlZW1haWwgTWVtYmVyMR8wHQYJKoZIhvcNAQkBFhBzbWhhdW5jaEBtYWMuY29t -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAn90dPsYS3LjfMY211OSYrDQLzwNYPlAL -7+/0XA+kdy8/rRnyEHFGwhNCDmg0B6pxC7z3xxJD/8GfCd+IYUUNUQV5m9MkxfP9pTVXZVIYLaBw -------=_Part_5028_7368284.1115579351471-- - diff --git a/actionmailer/test/fixtures/raw_email9 b/actionmailer/test/fixtures/raw_email9 deleted file mode 100644 index 02ea0b05c530a..0000000000000 --- a/actionmailer/test/fixtures/raw_email9 +++ /dev/null @@ -1,28 +0,0 @@ -Received: from xxx.xxx.xxx ([xxx.xxx.xxx.xxx] verified) - by xxx.com (CommuniGate Pro SMTP 4.2.8) - with SMTP id 2532598 for xxx@xxx.com; Wed, 23 Feb 2005 17:51:49 -0500 -Received-SPF: softfail - receiver=xxx.com; client-ip=xxx.xxx.xxx.xxx; envelope-from=xxx@xxx.xxx -quite Delivered-To: xxx@xxx.xxx -Received: by xxx.xxx.xxx (Wostfix, from userid xxx) - id 0F87F333; Wed, 23 Feb 2005 16:16:17 -0600 -Date: Wed, 23 Feb 2005 18:20:17 -0400 -From: "xxx xxx" -Message-ID: <4D6AA7EB.6490534@xxx.xxx> -To: xxx@xxx.com -Subject: Stop adware/spyware once and for all. -X-Scanned-By: MIMEDefang 2.11 (www dot roaringpenguin dot com slash mimedefang) - -You are infected with: -Ad Ware and Spy Ware - -Get your free scan and removal download now, -before it gets any worse. - -http://xxx.xxx.info?aid=3D13&?stat=3D4327kdzt - - - - -no more? (you will still be infected) -http://xxx.xxx.info/discon/?xxx@xxx.com diff --git a/actionmailer/test/fixtures/raw_email_quoted_with_0d0a b/actionmailer/test/fixtures/raw_email_quoted_with_0d0a deleted file mode 100644 index 8a2c25a5ddfcd..0000000000000 --- a/actionmailer/test/fixtures/raw_email_quoted_with_0d0a +++ /dev/null @@ -1,14 +0,0 @@ -Mime-Version: 1.0 (Apple Message framework v730) -Message-Id: <9169D984-4E0B-45EF-82D4-8F5E53AD7012@example.com> -From: foo@example.com -Subject: testing -Date: Mon, 6 Jun 2005 22:21:22 +0200 -To: blah@example.com -Content-Transfer-Encoding: quoted-printable -Content-Type: text/plain - -A fax has arrived from remote ID ''.=0D=0A-----------------------= --------------------------------------=0D=0ATime: 3/9/2006 3:50:52= - PM=0D=0AReceived from remote ID: =0D=0AInbound user ID XXXXXXXXXX, r= -outing code XXXXXXXXX=0D=0AResult: (0/352;0/0) Successful Send=0D=0AP= -age record: 1 - 1=0D=0AElapsed time: 00:58 on channel 11=0D=0A diff --git a/actionmailer/test/fixtures/raw_email_with_invalid_characters_in_content_type b/actionmailer/test/fixtures/raw_email_with_invalid_characters_in_content_type deleted file mode 100644 index a8ff7ed4cb476..0000000000000 --- a/actionmailer/test/fixtures/raw_email_with_invalid_characters_in_content_type +++ /dev/null @@ -1,104 +0,0 @@ -Return-Path: -Received: from some.isp.com by baci with ESMTP id 632BD5758 for ; Sun, 21 Oct 2007 19:38:21 +1000 -Date: Sun, 21 Oct 2007 19:38:13 +1000 -From: Mikel Lindsaar -Reply-To: Mikel Lindsaar -To: mikel.lindsaar@baci -Message-Id: <009601c813c6$19df3510$0437d30a@mikel091a> -Subject: Testing outlook -Mime-Version: 1.0 -Content-Type: multipart/alternative; boundary=----=_NextPart_000_0093_01C81419.EB75E850 -Delivered-To: mikel.lindsaar@baci -X-Mimeole: Produced By Microsoft MimeOLE V6.00.2900.3138 -X-Msmail-Priority: Normal - -This is a multi-part message in MIME format. - - -------=_NextPart_000_0093_01C81419.EB75E850 -Content-Type: text/plain; charset=iso-8859-1 -Content-Transfer-Encoding: Quoted-printable - -Hello -This is an outlook test - -So there. - -Me. - -------=_NextPart_000_0093_01C81419.EB75E850 -Content-Type: text/html; charset=iso-8859-1 -Content-Transfer-Encoding: Quoted-printable - - - - - - - - -
Hello
-
This is an outlook=20 -test
-
 
-
So there.
-
 
-
Me.
- - -------=_NextPart_000_0093_01C81419.EB75E850-- - - -Return-Path: -Received: from some.isp.com by baci with ESMTP id 632BD5758 for ; Sun, 21 Oct 2007 19:38:21 +1000 -Date: Sun, 21 Oct 2007 19:38:13 +1000 -From: Mikel Lindsaar -Reply-To: Mikel Lindsaar -To: mikel.lindsaar@baci -Message-Id: <009601c813c6$19df3510$0437d30a@mikel091a> -Subject: Testing outlook -Mime-Version: 1.0 -Content-Type: multipart/alternative; boundary=----=_NextPart_000_0093_01C81419.EB75E850 -Delivered-To: mikel.lindsaar@baci -X-Mimeole: Produced By Microsoft MimeOLE V6.00.2900.3138 -X-Msmail-Priority: Normal - -This is a multi-part message in MIME format. - - -------=_NextPart_000_0093_01C81419.EB75E850 -Content-Type: text/plain; charset=iso-8859-1 -Content-Transfer-Encoding: Quoted-printable - -Hello -This is an outlook test - -So there. - -Me. - -------=_NextPart_000_0093_01C81419.EB75E850 -Content-Type: text/html; charset=iso-8859-1 -Content-Transfer-Encoding: Quoted-printable - - - - - - - - -
Hello
-
This is an outlook=20 -test
-
 
-
So there.
-
 
-
Me.
- - -------=_NextPart_000_0093_01C81419.EB75E850-- - - diff --git a/actionmailer/test/fixtures/raw_email_with_nested_attachment b/actionmailer/test/fixtures/raw_email_with_nested_attachment deleted file mode 100644 index 429c408c5d2ef..0000000000000 --- a/actionmailer/test/fixtures/raw_email_with_nested_attachment +++ /dev/null @@ -1,100 +0,0 @@ -From jamis@37signals.com Thu Feb 22 11:20:31 2007 -Mime-Version: 1.0 (Apple Message framework v752.3) -Message-Id: <2CCE0408-10C7-4045-9B16-A1C11C31469B@37signals.com> -Content-Type: multipart/signed; - micalg=sha1; - boundary=Apple-Mail-42-587703407; - protocol="application/pkcs7-signature" -To: Jamis Buck -Subject: Testing attachments -From: Jamis Buck -Date: Thu, 22 Feb 2007 11:20:31 -0700 - - ---Apple-Mail-42-587703407 -Content-Type: multipart/mixed; - boundary=Apple-Mail-41-587703287 - - ---Apple-Mail-41-587703287 -Content-Transfer-Encoding: 7bit -Content-Type: text/plain; - charset=US-ASCII; - format=flowed - -Here is a test of an attachment via email. - -- Jamis - - ---Apple-Mail-41-587703287 -Content-Transfer-Encoding: base64 -Content-Type: image/png; - x-unix-mode=0644; - name=byo-ror-cover.png -Content-Disposition: inline; - filename=truncated.png - -iVBORw0KGgoAAAANSUhEUgAAAKUAAADXCAYAAAB7wZEQAAAABHNCSVQICAgIfAhkiAAAAAlwSFlz -AAALEgAACxIB0t1+/AAAABd0RVh0Q3JlYXRpb24gVGltZQAxLzI1LzIwMDeD9CJVAAAAGHRFWHRT -b2Z0d2FyZQBBZG9iZSBGaXJld29ya3NPsx9OAAAyBWlUWHRYTUw6Y29tLmFkb2JlLnhtcDw/eHBh -Y2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+Cjx4OnhtcG1l -dGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDQuMS1j -MDIwIDEuMjU1NzE2LCBUdWUgT2N0IDEwIDIwMDYgMjM6MTY6MzQiPgogICA8cmRmOlJERiB4bWxu -czpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAg -ICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp4YXA9Imh0 -dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iPgogICAgICAgICA8eGFwOkNyZWF0b3JUb29sPkFk -b2JlIEZpcmV3b3JrcyBDUzM8L3hhcDpDcmVhdG9yVG9vbD4KICAgICAgICAgPHhhcDpDcmVhdGVE -YXRlPjIwMDctMDEtMjVUMDU6Mjg6MjFaPC94YXA6Q3JlYXRlRGF0ZT4KICAgICAgICAgPHhhcDpN -b2RpZnlEYXRlPjIwMDctMDEtMjVUMDU6Mjg6MjFaPC94YXA6TW9kaWZ5RGF0ZT4KICAgICAgPC9y -ZGY6RGVzY3JpcHRpb24+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAg -ICAgICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiAgICAg -ICAgIDxkYzpmb3JtYXQ+aW1hZ2UvcG5nPC9kYzpmb3JtYXQ+CiAgICAgIDwvcmRmOkRlc2NyaXB0 -hhojpmnJMfaYFmSkXWg5PGCmHXVj/c9At0hSK2xGdd8F3muk0VFjb4f5Ue0ksQ8qAcq0delaXhdb -DjKNnF+3B3t9kObZYmk7AZgWYqO9anpR3wpM9sQ5XslB9a+kWyTtNb0fOmudzGHfPFBQDKesyycm -DBL7Cw5bXjIEuci+SSOm/LYnXDZu6iuPEj8lYBb+OU8xx1f9m+e5rhJiYKqjo5vHfiZp+VUkW9xc -Ufd6JHNWc47PkQqb9ie3SLEZB/ZqyAssiqURY+G35iOMZUrHbasHnb80QAPv9FHtAbJIyro7bi5b -ai2TEAKen5+LJNWrglZjm3UbZvt7KryA2J5b5J1jZF8kL6GzvG1Zqx54Y1y7J7n20wMOt9frG2sW -uwGP07kNz3732vf6bfvAvLldfS+9fts2euXY37D+R29FGZdlnhzV4TTFmPJduBP2RbNNua4rTqcT -Qt7Xy1KUB0AHSdP5AZQYvHZg7WD1XvYeMO1A9HhZPqMX5KXbMBrn2efxns/ee21674efxz4Tp/fq -2HZ648dgYaC1i3Vq1IbNPq3PvDTPezY9FaRISjvnzWqdgcWN8EJgjnNq+Z7ktOm9l2Nfth28EZi4 -bG/we5JwxM+Tql47/D/X6b38I8/RyxvxPJrX6zvQbo3h9jyJx+C0ALX327QETHl5eYlaYCT5rPTb -+5/rAq26t3lKIxV/p88hq6ptngdgCzoPjJqndiLfc/6y5A14WeDFGNPct4iUsJBV2bYzLEV7m83s -6Rp63VPhHKC/g/LzaU9qexJRr56043JWinqAtfZqsSm1sjoznthl54dtCqv+uL4nIY+oYWuc3+nH -kGfn8b0HQpvOYLQAZUDanbJs3jQhITZEgdarZK+cO6ySlL13rut5nFaN23s7u3Snz6eRPTkCoc2/ -Vp1zHfZVFpZ87FiMVLV1iqyK5rlzfji2GzjfDsodlD+Weo5UD4h6PwKqzQMqID0tq2VjjFVSMpis -ZLRAs7sePZBZAHI+gIanB8I7MD+femAceeUe2Kxa5jS950kZ1p5eNEdeX1+jFmSpZ+1EdWCsDcne -NPNgUHNw3aYpnzv9PGTX0uo94EtN9qq1rOdxe3kc79T8ukeHJJ8Fnxej6qlylbLLsjQLOy6Xy2a1 -kefs/N+nM7+S7IG5/E5Yc7F003pWErLjbH0O5cGadiMptSB/DZ5U5DI9yeg5MFYyMj8lC/Y7/Xjq -OZlWcnpg9aQfXz2HRq+Wn5xOp6gN8tWq8R44e2pfyzLYemEgprst+XXk2Zj2nXlbsG05BprndTMv -C3QRaXczshhVsHnMgfYn80Y2g5JureA6wBasPeP7LkE/jvZMJAaf/g/U2RelHsisvan5FqweIAHg -Pwc7L68GxvVDAAAAAElFTkSuQmCC - ---Apple-Mail-41-587703287-- - ---Apple-Mail-42-587703407 -Content-Transfer-Encoding: base64 -Content-Type: application/pkcs7-signature; - name=smime.p7s -Content-Disposition: attachment; - filename=smime.p7s - -MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAQAAoIIGJzCCAuAw -ggJJoAMCAQICEFjnFNYXwDEZRWY5EkfzopUwDQYJKoZIhvcNAQEFBQAwYjELMAkGA1UEBhMCWkEx -JTAjBgNVBAoTHFRoYXd0ZSBDb25zdWx0aW5nIChQdHkpIEx0ZC4xLDAqBgNVBAMTI1RoYXd0ZSBQ -ZXJzb25hbCBGcmVlbWFpbCBJc3N1aW5nIENBMB4XDTA2MDkxMjE3MDExMloXDTA3MDkxMjE3MDEx -MlowRTEfMB0GA1UEAxMWVGhhd3RlIEZyZWVtYWlsIE1lbWJlcjEiMCAGCSqGSIb3DQEJARYTamFt -aXNAMzdzaWduYWxzLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO2A9JeOFIFJ -G6z8pTcAldrZ2nMe+Xb1tNrbHgoVzN/QhHXM4qst2Ml93cmFLjMmwG7P9RJeU4oNx+jTqVoBB7NV -Ne1/o56Do0KhfMZ9iUDQdPLbkZMq4EEpFMdm6PyM3muRKwPhj66iAWe/osCb8DowUK2f66vaRx0Z -Y0MQHIIrXE02Ta4IfAhIfPqBLkZ4WgTYBHN9vMdYea1jF0GO4gqGk1wqwb3yxv2QMYMbwJ6SI+k/ -ZjkSR/OilTCBhwYLKoZIhvcNAQkQAgsxeKB2MGIxCzAJBgNVBAYTAlpBMSUwIwYDVQQKExxUaGF3 -dGUgQ29uc3VsdGluZyAoUHR5KSBMdGQuMSwwKgYDVQQDEyNUaGF3dGUgUGVyc29uYWwgRnJlZW1h -aWwgSXNzdWluZyBDQQIQWOcU1hfAMRlFZjkSR/OilTANBgkqhkiG9w0BAQEFAASCAQCfwQiC3v6/ -yleRDGv3bJ4nQYQ+c3mz3+mn3Xi6uU35n3piwxZZaWRdmLyiXPvU+QReHpSf3l2qsEZM3sdE0XF9 -eRul/+QTFJcDNXOEAxG1zC2Gpz+6c6RrX4Ou12Pwkp+pNrZWTSY/mZgdqcArupOBcZi7qBjoWcy5 -wb54dfvSSjrjmqLbkH/E8ww/6gGQuU/xXpAUZgUrTmQHrNKeIdSh5oDkOxFaFWvnmb8Z/2ixKqW/ -Ux6WqamyvBtTs/5YBEtnpZOk+uVoscYEUBhU+DVJ2OSvTdXSivMtBdXmGTsG22k+P1NGUHi/A7ev -xPaO0uk4V8xyjNlN4HPuGpkrlXwPAAAAAAAA - ---Apple-Mail-42-587703407-- diff --git a/actionmailer/test/fixtures/raw_email_with_partially_quoted_subject b/actionmailer/test/fixtures/raw_email_with_partially_quoted_subject deleted file mode 100644 index e86108da1ea54..0000000000000 --- a/actionmailer/test/fixtures/raw_email_with_partially_quoted_subject +++ /dev/null @@ -1,14 +0,0 @@ -From jamis@37signals.com Mon May 2 16:07:05 2005 -Mime-Version: 1.0 (Apple Message framework v622) -Content-Transfer-Encoding: base64 -Message-Id: -Content-Type: text/plain; - charset=EUC-KR; - format=flowed -To: jamis@37signals.com -From: Jamis Buck -Subject: Re: Test: =?UTF-8?B?Iua8ouWtlyI=?= mid =?UTF-8?B?Iua8ouWtlyI=?= tail -Date: Mon, 2 May 2005 16:07:05 -0600 - -tOu6zrrQwMcguLbC+bChwfa3ziwgv+y4rrTCIMfPs6q01MC7ILnPvcC0z7TZLg0KDQrBpiDAzLin -wLogSmFtaXPA1LTPtNku diff --git a/actionmailer/test/fixtures/second_mailer/share.erb b/actionmailer/test/fixtures/second_mailer/share.erb deleted file mode 100644 index 9a540106720f1..0000000000000 --- a/actionmailer/test/fixtures/second_mailer/share.erb +++ /dev/null @@ -1 +0,0 @@ -second mail diff --git a/actionmailer/test/fixtures/test_helper_mailer/welcome b/actionmailer/test/fixtures/test_helper_mailer/welcome new file mode 100644 index 0000000000000..61ce70d578709 --- /dev/null +++ b/actionmailer/test/fixtures/test_helper_mailer/welcome @@ -0,0 +1 @@ +Welcome! \ No newline at end of file diff --git a/actionmailer/test/fixtures/test_mailer/_subtemplate.text.erb b/actionmailer/test/fixtures/test_mailer/_subtemplate.text.erb deleted file mode 100644 index 3b4ba35f20488..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/_subtemplate.text.erb +++ /dev/null @@ -1 +0,0 @@ -let's go! \ No newline at end of file diff --git a/actionmailer/test/fixtures/test_mailer/custom_templating_extension.html.haml b/actionmailer/test/fixtures/test_mailer/custom_templating_extension.html.haml deleted file mode 100644 index 8dcf9746ccf27..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/custom_templating_extension.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%p Hello there, - -%p - Mr. - = @recipient - from haml \ No newline at end of file diff --git a/actionmailer/test/fixtures/test_mailer/custom_templating_extension.text.haml b/actionmailer/test/fixtures/test_mailer/custom_templating_extension.text.haml deleted file mode 100644 index 8dcf9746ccf27..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/custom_templating_extension.text.haml +++ /dev/null @@ -1,6 +0,0 @@ -%p Hello there, - -%p - Mr. - = @recipient - from haml \ No newline at end of file diff --git a/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.html.erb b/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.html.erb deleted file mode 100644 index 946d99ede50e3..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.html.erb +++ /dev/null @@ -1,10 +0,0 @@ - - - HTML formatted message to <%= @recipient %>. - - - - - HTML formatted message to <%= @recipient %>. - - diff --git a/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.html.erb~ b/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.html.erb~ deleted file mode 100644 index 946d99ede50e3..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.html.erb~ +++ /dev/null @@ -1,10 +0,0 @@ - - - HTML formatted message to <%= @recipient %>. - - - - - HTML formatted message to <%= @recipient %>. - - diff --git a/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.ignored.erb b/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.ignored.erb deleted file mode 100644 index 6940419d47a1e..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.ignored.erb +++ /dev/null @@ -1 +0,0 @@ -Ignored when searching for implicitly multipart parts. diff --git a/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.rhtml.bak b/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.rhtml.bak deleted file mode 100644 index 6940419d47a1e..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.rhtml.bak +++ /dev/null @@ -1 +0,0 @@ -Ignored when searching for implicitly multipart parts. diff --git a/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.text.erb b/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.text.erb deleted file mode 100644 index a6c8d54cf9faa..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.text.erb +++ /dev/null @@ -1,2 +0,0 @@ -Plain text to <%= @recipient %>. -Plain text to <%= @recipient %>. diff --git a/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.yaml.erb b/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.yaml.erb deleted file mode 100644 index c14348c7707fd..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/implicitly_multipart_example.yaml.erb +++ /dev/null @@ -1 +0,0 @@ -yaml to: <%= @recipient %> \ No newline at end of file diff --git a/actionmailer/test/fixtures/test_mailer/included_subtemplate.text.erb b/actionmailer/test/fixtures/test_mailer/included_subtemplate.text.erb deleted file mode 100644 index a93c30ea1a732..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/included_subtemplate.text.erb +++ /dev/null @@ -1 +0,0 @@ -Hey Ho, <%= render :partial => "subtemplate" %> \ No newline at end of file diff --git a/actionmailer/test/fixtures/test_mailer/multipart_alternative.html.erb b/actionmailer/test/fixtures/test_mailer/multipart_alternative.html.erb deleted file mode 100644 index 73ea14f82f73e..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/multipart_alternative.html.erb +++ /dev/null @@ -1 +0,0 @@ -foo <%= @foo %> \ No newline at end of file diff --git a/actionmailer/test/fixtures/test_mailer/multipart_alternative.plain.erb b/actionmailer/test/fixtures/test_mailer/multipart_alternative.plain.erb deleted file mode 100644 index 779fe4c1eaa96..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/multipart_alternative.plain.erb +++ /dev/null @@ -1 +0,0 @@ -foo: <%= @foo %> \ No newline at end of file diff --git a/actionmailer/test/fixtures/test_mailer/rxml_template.rxml b/actionmailer/test/fixtures/test_mailer/rxml_template.rxml deleted file mode 100644 index d566bd8d7cbf4..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/rxml_template.rxml +++ /dev/null @@ -1,2 +0,0 @@ -xml.instruct! -xml.test \ No newline at end of file diff --git a/actionmailer/test/fixtures/test_mailer/signed_up.html.erb b/actionmailer/test/fixtures/test_mailer/signed_up.html.erb deleted file mode 100644 index 7afe1f651c63b..0000000000000 --- a/actionmailer/test/fixtures/test_mailer/signed_up.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -Hello there, - -Mr. <%= @recipient %> \ No newline at end of file diff --git a/actionmailer/test/fixtures/url_test_mailer/exercise_url_for.erb b/actionmailer/test/fixtures/url_test_mailer/exercise_url_for.erb new file mode 100644 index 0000000000000..0322c1191eaa0 --- /dev/null +++ b/actionmailer/test/fixtures/url_test_mailer/exercise_url_for.erb @@ -0,0 +1 @@ +<%= url_for(@options) %> <%= @url %> diff --git a/actionmailer/test/form_builder_test.rb b/actionmailer/test/form_builder_test.rb new file mode 100644 index 0000000000000..460acd04be822 --- /dev/null +++ b/actionmailer/test/form_builder_test.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "mailers/form_builder_mailer" + +class MailerFormBuilderTest < ActiveSupport::TestCase + def test_default_form_builder_assigned + email = FormBuilderMailer.welcome + assert_includes(email.body.encoded, "hi from SpecializedFormBuilder") + end +end diff --git a/actionmailer/test/i18n_with_controller_test.rb b/actionmailer/test/i18n_with_controller_test.rb index 7040ae6f8d148..5e5be4ff2b2a3 100644 --- a/actionmailer/test/i18n_with_controller_test.rb +++ b/actionmailer/test/i18n_with_controller_test.rb @@ -1,46 +1,76 @@ -require 'abstract_unit' -require 'action_controller' +# frozen_string_literal: true + +require "abstract_unit" +require "action_view" +require "action_controller" class I18nTestMailer < ActionMailer::Base configure do |c| - c.assets_dir = '' + c.assets_dir = "" end def mail_with_i18n_subject(recipient) @recipient = recipient I18n.locale = :de - mail(:to => recipient, :subject => "#{I18n.t :email_subject} #{recipient}", - :from => "system@loudthinking.com", :date => Time.local(2004, 12, 12)) + mail(to: recipient, subject: I18n.t(:email_subject), + from: "system@loudthinking.com", date: Time.local(2004, 12, 12)) end end class TestController < ActionController::Base def send_mail - I18nTestMailer.mail_with_i18n_subject("test@localhost").deliver - render :text => 'Mail sent' + email = I18nTestMailer.mail_with_i18n_subject("test@localhost").deliver_now + render plain: "Mail sent - Subject: #{email.subject}" end end class ActionMailerI18nWithControllerTest < ActionDispatch::IntegrationTest Routes = ActionDispatch::Routing::RouteSet.new Routes.draw do - match ':controller(/:action(/:id))' + ActionDispatch.deprecator.silence do + get ":controller(/:action(/:id))" + end end - def app - Routes + class RoutedRackApp + attr_reader :routes + + def initialize(routes, &blk) + @routes = routes + @stack = ActionDispatch::MiddlewareStack.new(&blk).build(@routes) + end + + def call(env) + @stack.call(env) + end end - def setup - I18n.backend.store_translations('de', :email_subject => '[Signed up] Welcome') + APP = RoutedRackApp.new(Routes) + + def app + APP end - def teardown - I18n.locale = :en + teardown do + I18n.locale = I18n.default_locale end def test_send_mail - get '/test/send_mail' - assert_equal "Mail sent", @response.body + stub_any_instance(Mail::SMTP, instance: Mail::SMTP.new({})) do |instance| + assert_called(instance, :deliver!) do + with_translation "de", email_subject: "[Anmeldung] Willkommen" do + get "/test/send_mail" + assert_equal "Mail sent - Subject: [Anmeldung] Willkommen", @response.body + end + end + end end + + private + def with_translation(locale, data) + I18n.backend.store_translations(locale, data) + yield + ensure + I18n.backend.reload! + end end diff --git a/actionmailer/test/log_subscriber_test.rb b/actionmailer/test/log_subscriber_test.rb index 5f52a1bd697bf..4272430a9bfd2 100644 --- a/actionmailer/test/log_subscriber_test.rb +++ b/actionmailer/test/log_subscriber_test.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + require "abstract_unit" -require 'mailers/base_mailer' +require "mailers/base_mailer" require "active_support/log_subscriber/test_helper" require "action_mailer/log_subscriber" @@ -11,9 +13,12 @@ def setup ActionMailer::LogSubscriber.attach_to :action_mailer end - class TestMailer < ActionMailer::Base - def receive(mail) - # Do nothing + class BogusDelivery + def initialize(*) + end + + def deliver!(mail) + raise "failed" end end @@ -22,21 +27,43 @@ def set_logger(logger) end def test_deliver_is_notified - BaseMailer.welcome.deliver + BaseMailer.welcome(message_id: "123@abc").deliver_now wait + assert_equal(1, @logger.logged(:info).size) - assert_match(/Sent mail to system@test.lindsaar.net/, @logger.logged(:info).first) - assert_equal(1, @logger.logged(:debug).size) - assert_match(/Welcome/, @logger.logged(:debug).first) + assert_match(/Delivered mail 123@abc/, @logger.logged(:info).first) + + assert_equal(2, @logger.logged(:debug).size) + assert_match(/BaseMailer#welcome: processed outbound mail in [\d.]+ms/, @logger.logged(:debug).first) + assert_match(/Welcome/, @logger.logged(:debug).second) + ensure + BaseMailer.deliveries.clear end - def test_receive_is_notified - fixture = File.read(File.dirname(__FILE__) + "/fixtures/raw_email") - TestMailer.receive(fixture) + def test_deliver_message_when_perform_deliveries_is_false + BaseMailer.welcome_without_deliveries(message_id: "123@abc").deliver_now wait + + assert_equal(1, @logger.logged(:info).size) + assert_match("Skipped delivery of mail 123@abc as `perform_deliveries` is false", @logger.logged(:info).first) + + assert_equal(2, @logger.logged(:debug).size) + assert_match(/BaseMailer#welcome_without_deliveries: processed outbound mail in [\d.]+ms/, @logger.logged(:debug).first) + assert_match("Welcome", @logger.logged(:debug).second) + ensure + BaseMailer.deliveries.clear + end + + def test_deliver_message_when_exception_happened + previous_delivery_method = BaseMailer.delivery_method + BaseMailer.delivery_method = BogusDelivery + + assert_raises(RuntimeError) { BaseMailer.welcome(message_id: "123@abc").deliver_now } + wait + assert_equal(1, @logger.logged(:info).size) - assert_match(/Received mail/, @logger.logged(:info).first) - assert_equal(1, @logger.logged(:debug).size) - assert_match(/Jamis/, @logger.logged(:debug).first) + assert_equal('Failed delivery of mail 123@abc error_class=RuntimeError error_message="failed"', @logger.logged(:info).first) + ensure + BaseMailer.delivery_method = previous_delivery_method end -end \ No newline at end of file +end diff --git a/actionmailer/test/mail_helper_test.rb b/actionmailer/test/mail_helper_test.rb index d8a73e6c468d3..06729826f8480 100644 --- a/actionmailer/test/mail_helper_test.rb +++ b/actionmailer/test/mail_helper_test.rb @@ -1,16 +1,18 @@ -require 'abstract_unit' +# frozen_string_literal: true + +require "abstract_unit" class HelperMailer < ActionMailer::Base def use_mail_helper - @text = "But soft! What light through yonder window breaks? It is the east, " + - "and Juliet is the sun. Arise, fair sun, and kill the envious moon, " + - "which is sick and pale with grief that thou, her maid, art far more " + - "fair than she. Be not her maid, for she is envious! Her vestal " + - "livery is but sick and green, and none but fools do wear it. Cast " + + @text = "But soft! What light through yonder window breaks? It is the east, " \ + "and Juliet is the sun. Arise, fair sun, and kill the envious moon, " \ + "which is sick and pale with grief that thou, her maid, art far more " \ + "fair than she. Be not her maid, for she is envious! Her vestal " \ + "livery is but sick and green, and none but fools do wear it. Cast " \ "it off!" mail_with_defaults do |format| - format.html { render(:inline => "<%= block_format @text %>") } + format.html { render(inline: "<%= block_format @text %>") } end end @@ -18,7 +20,7 @@ def use_format_paragraph @text = "But soft! What light through yonder window breaks?" mail_with_defaults do |format| - format.html { render(:inline => "<%= format_paragraph @text, 15, 1 %>") } + format.html { render(inline: "<%= format_paragraph @text, 15, 1 %>") } end end @@ -26,19 +28,19 @@ def use_format_paragraph_with_long_first_word @text = "Antidisestablishmentarianism is very long." mail_with_defaults do |format| - format.html { render(:inline => "<%= format_paragraph @text, 10, 1 %>") } + format.html { render(inline: "<%= format_paragraph @text, 10, 1 %>") } end end def use_mailer mail_with_defaults do |format| - format.html { render(:inline => "<%= mailer.message.subject %>") } + format.html { render(inline: "<%= mailer.message.subject %>") } end end def use_message mail_with_defaults do |format| - format.html { render(:inline => "<%= message.subject %>") } + format.html { render(inline: "<%= message.subject %>") } end end @@ -55,16 +57,21 @@ def use_block_format TEXT mail_with_defaults do |format| - format.html { render(:inline => "<%= block_format @text %>") } + format.html { render(inline: "<%= block_format @text %>") } end end - protected - - def mail_with_defaults(&block) - mail(:to => "test@localhost", :from => "tester@example.com", - :subject => "using helpers", &block) + def use_cache + mail_with_defaults do |format| + format.html { render(inline: "<% cache(:foo) do %>Greetings from a cache helper block<% end %>") } + end end + + private + def mail_with_defaults(&block) + mail(to: "test@localhost", from: "tester@example.com", + subject: "using helpers", &block) + end end class MailerHelperTest < ActionMailer::TestCase @@ -107,5 +114,24 @@ def test_use_block_format TEXT assert_equal expected.gsub("\n", "\r\n"), mail.body.encoded end -end + def test_use_cache + assert_nothing_raised do + mail = HelperMailer.use_cache + assert_equal "Greetings from a cache helper block", mail.body.encoded + end + end + + def helper + Object.new.extend(ActionMailer::MailHelper) + end + + def test_block_format + assert_equal " * foo\n", helper.block_format(" * foo") + assert_equal " * foo\n", helper.block_format(" * foo") + assert_equal " * foo\n", helper.block_format("* foo") + assert_equal " * foo\n*bar", helper.block_format("* foo*bar") + assert_equal " * foo\n * bar\n", helper.block_format("* foo * bar") + assert_equal " *", helper.block_format("* ") + end +end diff --git a/actionmailer/test/mail_layout_test.rb b/actionmailer/test/mail_layout_test.rb index 71e93c29f180b..16d77ed61dc0f 100644 --- a/actionmailer/test/mail_layout_test.rb +++ b/actionmailer/test/mail_layout_test.rb @@ -1,9 +1,11 @@ -require 'abstract_unit' +# frozen_string_literal: true + +require "abstract_unit" class AutoLayoutMailer < ActionMailer::Base - default :to => 'test@localhost', - :subject => "You have a mail", - :from => "tester@example.com" + default to: "test@localhost", + subject: "You have a mail", + from: "tester@example.com" def hello mail() @@ -11,16 +13,16 @@ def hello def spam @world = "Earth" - mail(:body => render(:inline => "Hello, <%= @world %>", :layout => 'spam')) + mail(body: render(inline: "Hello, <%= @world %>", layout: "spam")) end def nolayout @world = "Earth" - mail(:body => render(:inline => "Hello, <%= @world %>", :layout => false)) + mail(body: render(inline: "Hello, <%= @world %>", layout: false)) end def multipart(type = nil) - mail(:content_type => type) do |format| + mail(content_type: type) do |format| format.text { render } format.html { render } end @@ -28,11 +30,11 @@ def multipart(type = nil) end class ExplicitLayoutMailer < ActionMailer::Base - layout 'spam', :except => [:logout] + layout "spam", except: [:logout] - default :to => 'test@localhost', - :subject => "You have a mail", - :from => "tester@example.com" + default to: "test@localhost", + subject: "You have a mail", + from: "tester@example.com" def signup mail() @@ -44,16 +46,6 @@ def logout end class LayoutMailerTest < ActiveSupport::TestCase - def setup - set_delivery_method :test - ActionMailer::Base.perform_deliveries = true - ActionMailer::Base.deliveries.clear - end - - def teardown - restore_delivery_method - end - def test_should_pickup_default_layout mail = AutoLayoutMailer.hello assert_equal "Hello from layout Inside", mail.body.to_s.strip @@ -64,10 +56,10 @@ def test_should_pickup_multipart_layout assert_equal "multipart/alternative", mail.mime_type assert_equal 2, mail.parts.size - assert_equal 'text/plain', mail.parts.first.mime_type + assert_equal "text/plain", mail.parts.first.mime_type assert_equal "text/plain layout - text/plain multipart", mail.parts.first.body.to_s - assert_equal 'text/html', mail.parts.last.mime_type + assert_equal "text/html", mail.parts.last.mime_type assert_equal "Hello from layout text/html multipart", mail.parts.last.body.to_s end @@ -76,10 +68,10 @@ def test_should_pickup_multipartmixed_layout assert_equal "multipart/mixed", mail.mime_type assert_equal 2, mail.parts.size - assert_equal 'text/plain', mail.parts.first.mime_type + assert_equal "text/plain", mail.parts.first.mime_type assert_equal "text/plain layout - text/plain multipart", mail.parts.first.body.to_s - assert_equal 'text/html', mail.parts.last.mime_type + assert_equal "text/html", mail.parts.last.mime_type assert_equal "Hello from layout text/html multipart", mail.parts.last.body.to_s end diff --git a/actionmailer/test/mailers/asset_mailer.rb b/actionmailer/test/mailers/asset_mailer.rb index f54a50d00d937..7a9aba2629cf9 100644 --- a/actionmailer/test/mailers/asset_mailer.rb +++ b/actionmailer/test/mailers/asset_mailer.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + class AssetMailer < ActionMailer::Base self.mailer_name = "asset_mailer" def welcome - mail + mail end end diff --git a/actionmailer/test/mailers/base_mailer.rb b/actionmailer/test/mailers/base_mailer.rb index e55d72fdb43f9..4a73d40bafb87 100644 --- a/actionmailer/test/mailers/base_mailer.rb +++ b/actionmailer/test/mailers/base_mailer.rb @@ -1,13 +1,15 @@ +# frozen_string_literal: true + class BaseMailer < ActionMailer::Base self.mailer_name = "base_mailer" - default :to => 'system@test.lindsaar.net', - :from => 'jose@test.plataformatec.com', - :reply_to => 'mikel@test.lindsaar.net' + default to: "system@test.lindsaar.net", + from: "jose@test.plataformatec.com", + reply_to: email_address_with_name("mikel@test.lindsaar.net", "Mikel") def welcome(hash = {}) - headers['X-SPAM'] = "Not SPAM" - mail({:subject => "The first email on new API!"}.merge!(hash)) + headers["X-SPAM"] = "Not SPAM" + mail({ subject: "The first email on new API!" }.merge!(hash)) end def welcome_with_headers(hash = {}) @@ -16,7 +18,22 @@ def welcome_with_headers(hash = {}) end def welcome_from_another_path(path) - mail(:template_name => "welcome", :template_path => path) + mail(template_name: "welcome", template_path: path) + end + + def welcome_without_deliveries(hash = {}) + mail({ template_name: "welcome" }.merge!(hash)) + mail.perform_deliveries = false + end + + def with_name + to = email_address_with_name("sunny@example.com", "Sunny") + mail(template_name: "welcome", to: to) + end + + def with_blank_name + to = email_address_with_name("sunny@example.com", "") + mail(template_name: "welcome", to: to) end def html_only(hash = {}) @@ -28,30 +45,40 @@ def plain_text_only(hash = {}) end def inline_attachment - attachments.inline['logo.png'] = "\312\213\254\232" + attachments.inline["logo.png"] = "\312\213\254\232" + mail + end + + def inline_and_other_attachments + attachments.inline["logo.png"] = "\312\213\254\232" + attachments["certificate.pdf"] = "This is test File content" mail end def attachment_with_content(hash = {}) - attachments['invoice.pdf'] = 'This is test File content' + attachments["invoice.pdf"] = "This is test File content" mail(hash) end def attachment_with_hash - attachments['invoice.jpg'] = { :data => "\312\213\254\232)b", - :mime_type => "image/x-jpg", - :transfer_encoding => "base64" } + attachments["invoice.jpg"] = { data: ::Base64.encode64("\312\213\254\232)b"), + mime_type: "image/x-jpg", + transfer_encoding: "base64" } mail end def attachment_with_hash_default_encoding - attachments['invoice.jpg'] = { :data => "\312\213\254\232)b", - :mime_type => "image/x-jpg" } + attachments["invoice.jpg"] = { data: "\312\213\254\232)b", + mime_type: "image/x-jpg" } mail end def implicit_multipart(hash = {}) - attachments['invoice.pdf'] = 'This is test File content' if hash.delete(:attachments) + attachments["invoice.pdf"] = "This is test File content" if hash.delete(:attachments) + mail(hash) + end + + def implicit_multipart_formats(hash = {}) mail(hash) end @@ -60,10 +87,10 @@ def implicit_with_locale(hash = {}) end def explicit_multipart(hash = {}) - attachments['invoice.pdf'] = 'This is test File content' if hash.delete(:attachments) + attachments["invoice.pdf"] = "This is test File content" if hash.delete(:attachments) mail(hash) do |format| - format.text { render :text => "TEXT Explicit Multipart" } - format.html { render :text => "HTML Explicit Multipart" } + format.text { render plain: "TEXT Explicit Multipart" } + format.html { render plain: "HTML Explicit Multipart" } end end @@ -76,14 +103,20 @@ def explicit_multipart_templates(hash = {}) def explicit_multipart_with_any(hash = {}) mail(hash) do |format| - format.any(:text, :html){ render :text => "Format with any!" } + format.any(:text, :html) { render plain: "Format with any!" } + end + end + + def explicit_without_specifying_format_with_any(hash = {}) + mail(hash) do |format| + format.any end end def explicit_multipart_with_options(include_html = false) mail do |format| - format.text(:content_transfer_encoding => "base64"){ render "welcome" } - format.html{ render "welcome" } if include_html + format.text(content_transfer_encoding: "base64") { render "welcome" } + format.html { render "welcome" } if include_html end end @@ -94,25 +127,44 @@ def explicit_multipart_with_one_template(hash = {}) end end - def implicit_different_template(template_name='') - mail(:template_name => template_name) + def implicit_different_template(template_name = "") + mail(template_name: template_name) + end + + def implicit_different_template_with_block(template_name = "") + mail(template_name: template_name) do |format| + format.text + format.html + end end - def explicit_different_template(template_name='') + def explicit_different_template(template_name = "") mail do |format| - format.text { render :template => "#{mailer_name}/#{template_name}" } - format.html { render :template => "#{mailer_name}/#{template_name}" } + format.text { render template: "#{mailer_name}/#{template_name}" } + format.html { render template: "#{mailer_name}/#{template_name}" } end end - def different_layout(layout_name='') + def different_layout(layout_name = "") mail do |format| - format.text { render :layout => layout_name } - format.html { render :layout => layout_name } + format.text { render layout: layout_name } + format.html { render layout: layout_name } end end def email_with_translations - mail :body => render("email_with_translations", :formats => [:html]) + mail body: render("email_with_translations", formats: [:html]) + end + + def without_mail_call + end + + def with_nil_as_return_value + mail(template_name: "welcome") + nil + end + + def with_subject_interpolations + mail(subject: default_i18n_subject(rapper_or_impersonator: "Slim Shady"), body: "") end end diff --git a/actionmailer/test/mailers/caching_mailer.rb b/actionmailer/test/mailers/caching_mailer.rb new file mode 100644 index 0000000000000..02f0c6c103b56 --- /dev/null +++ b/actionmailer/test/mailers/caching_mailer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class CachingMailer < ActionMailer::Base + self.mailer_name = "caching_mailer" + + def fragment_cache + mail(subject: "welcome", template_name: "fragment_cache") + end + + def fragment_cache_in_partials + mail(subject: "welcome", template_name: "fragment_cache_in_partials") + end + + def skip_fragment_cache_digesting + mail(subject: "welcome", template_name: "skip_fragment_cache_digesting") + end + + def fragment_caching_options + mail(subject: "welcome", template_name: "fragment_caching_options") + end + + def multipart_cache + mail(subject: "welcome", template_name: "multipart_cache") + end +end diff --git a/actionmailer/test/mailers/callback_mailer.rb b/actionmailer/test/mailers/callback_mailer.rb new file mode 100644 index 0000000000000..86e6c005d4f56 --- /dev/null +++ b/actionmailer/test/mailers/callback_mailer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +CallbackMailerError = Class.new(StandardError) +class CallbackMailer < ActionMailer::Base + cattr_accessor :rescue_from_error + cattr_accessor :after_deliver_instance + cattr_accessor :around_deliver_instance + cattr_accessor :abort_before_deliver + cattr_accessor :around_handles_error + + rescue_from CallbackMailerError do |error| + @@rescue_from_error = error + end + + before_deliver do + throw :abort if @@abort_before_deliver + end + + after_deliver do + @@after_deliver_instance = self + end + + around_deliver do |mailer, block| + @@around_deliver_instance = self + block.call + rescue StandardError + raise unless @@around_handles_error + end + + def test_message(*) + mail(from: "test-sender@test.com", to: "test-receiver@test.com", subject: "Test Subject", body: "Test Body") + end + + def test_raise_action + raise CallbackMailerError, "boom action processing" + end +end diff --git a/actionmailer/test/mailers/delayed_mailer.rb b/actionmailer/test/mailers/delayed_mailer.rb new file mode 100644 index 0000000000000..37c7590d97bb2 --- /dev/null +++ b/actionmailer/test/mailers/delayed_mailer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class DelayedMailerError < StandardError; end + +class DelayedMailer < ActionMailer::Base + self.deliver_later_queue_name = :delayed_mailers + + cattr_accessor :last_error + cattr_accessor :last_rescue_from_instance + + rescue_from DelayedMailerError do |error| + @@last_error = error + @@last_rescue_from_instance = self + end + + rescue_from ActiveJob::DeserializationError do |error| + @@last_error = error + @@last_rescue_from_instance = self + end + + def test_message(*) + mail(from: "test-sender@test.com", to: "test-receiver@test.com", subject: "Test Subject", body: "Test Body") + end + + def test_kwargs(argument:) + mail(from: "test-sender@test.com", to: "test-receiver@test.com", subject: "Test Subject", body: "Test Body") + end + + def test_raise(klass_name) + raise klass_name.constantize, "boom" + end +end diff --git a/actionmailer/test/mailers/form_builder_mailer.rb b/actionmailer/test/mailers/form_builder_mailer.rb new file mode 100644 index 0000000000000..8e2f00a01b20f --- /dev/null +++ b/actionmailer/test/mailers/form_builder_mailer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class FormBuilderMailer < ActionMailer::Base + class SpecializedFormBuilder < ActionView::Helpers::FormBuilder + def message + "hi from SpecializedFormBuilder" + end + end + + default_form_builder SpecializedFormBuilder + + def welcome + mail(to: "email@example.com") + end +end diff --git a/actionmailer/test/mailers/params_mailer.rb b/actionmailer/test/mailers/params_mailer.rb new file mode 100644 index 0000000000000..84aa336311313 --- /dev/null +++ b/actionmailer/test/mailers/params_mailer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ParamsMailer < ActionMailer::Base + before_action { @inviter, @invitee = params[:inviter], params[:invitee] } + + default to: Proc.new { @invitee }, from: -> { @inviter } + + def invitation + mail(subject: "Welcome to the project!") do |format| + format.text { render plain: "So says #{@inviter}" } + end + end +end diff --git a/actionmailer/test/mailers/proc_mailer.rb b/actionmailer/test/mailers/proc_mailer.rb index 43916e1421449..75ee5dd9b61c5 100644 --- a/actionmailer/test/mailers/proc_mailer.rb +++ b/actionmailer/test/mailers/proc_mailer.rb @@ -1,16 +1,24 @@ +# frozen_string_literal: true + class ProcMailer < ActionMailer::Base - default :to => 'system@test.lindsaar.net', - 'X-Proc-Method' => Proc.new { Time.now.to_i.to_s }, - :subject => Proc.new { give_a_greeting } + default to: "system@test.lindsaar.net", + "X-Proc-Method" => Proc.new { Time.now.to_i.to_s }, + subject: Proc.new { give_a_greeting }, + "x-has-to-proc" => :symbol, + "X-Lambda-Arity-0" => ->() { "0" }, + "X-Lambda-Arity-1-arg" => ->(arg) { arg.computed_value }, + "X-Lambda-Arity-1-self" => ->(_) { self.computed_value } def welcome mail end - private - - def give_a_greeting - "Thanks for signing up this afternoon" + def computed_value + "complex_value" end + private + def give_a_greeting + "Thanks for signing up this afternoon" + end end diff --git a/actionmailer/test/message_delivery_test.rb b/actionmailer/test/message_delivery_test.rb new file mode 100644 index 0000000000000..81061ff25b5d7 --- /dev/null +++ b/actionmailer/test/message_delivery_test.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_job" +require "mailers/delayed_mailer" + +class MessageDeliveryTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + setup do + @previous_logger = ActiveJob::Base.logger + @previous_delivery_method = ActionMailer::Base.delivery_method + + ActiveJob::Base.logger = Logger.new(nil) + ActionMailer::Base.delivery_method = :test + + ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = true + ActiveJob::Base.queue_adapter.perform_enqueued_jobs = true + + DelayedMailer.last_error = nil + DelayedMailer.last_rescue_from_instance = nil + + @mail = DelayedMailer.test_message(1, 2, 3) + end + + teardown do + ActionMailer::Base.deliveries.clear + + ActiveJob::Base.logger = @previous_logger + ActionMailer::Base.delivery_method = @previous_delivery_method + + DelayedMailer.last_error = nil + DelayedMailer.last_rescue_from_instance = nil + end + + test "should have a message" do + assert @mail.message + end + + test "its message should be a Mail::Message" do + assert_equal Mail::Message, @mail.message.class + end + + test "should respond to .deliver_later" do + assert_respond_to @mail, :deliver_later + end + + test "should respond to .deliver_later!" do + assert_respond_to @mail, :deliver_later! + end + + test "should respond to .deliver_now" do + assert_respond_to @mail, :deliver_now + end + + test "should respond to .deliver_now!" do + assert_respond_to @mail, :deliver_now! + end + + test "should enqueue the email with :deliver_now delivery method" do + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", args: [1, 2, 3]]) do + @mail.deliver_later + end + end + + test "should enqueue the email with :deliver_now! delivery method" do + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now!", args: [1, 2, 3]]) do + @mail.deliver_later! + end + end + + test "should enqueue delivery with a delay" do + travel_to Time.new(2004, 11, 24, 1, 4, 44) do + assert_performed_with(job: ActionMailer::MailDeliveryJob, at: Time.current + 10.minutes, args: ["DelayedMailer", "test_message", "deliver_now", args: [1, 2, 3]]) do + @mail.deliver_later wait: 10.minutes + end + end + end + + test "should enqueue delivery with a priority" do + job = @mail.deliver_later priority: 10 + assert_equal 10, job.priority + end + + test "should enqueue delivery at a specific time" do + later_time = Time.current + 1.hour + assert_performed_with(job: ActionMailer::MailDeliveryJob, at: later_time, args: ["DelayedMailer", "test_message", "deliver_now", args: [1, 2, 3]]) do + @mail.deliver_later wait_until: later_time + end + end + + test "should enqueue delivery on the correct queue" do + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", args: [1, 2, 3]], queue: "delayed_mailers") do + @mail.deliver_later + end + end + + test "should enqueue delivery with the correct job" do + old_delivery_job = DelayedMailer.delivery_job + DelayedMailer.delivery_job = DummyJob + + assert_performed_with(job: DummyJob, args: ["DelayedMailer", "test_message", "deliver_now", args: [1, 2, 3]]) do + @mail.deliver_later + end + + DelayedMailer.delivery_job = old_delivery_job + end + + class DummyJob < ActionMailer::MailDeliveryJob; end + + test "delivery queue can be overridden when enqueuing mail" do + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", args: [1, 2, 3]], queue: "another_queue") do + @mail.deliver_later(queue: :another_queue) + end + end + + test "delivery queue can be overridden in subclasses" do + previous_queue_name = DelayedMailer.deliver_later_queue_name + DelayedMailer.deliver_later_queue_name = :throttled_mailers + + assert_equal :throttled_mailers, DelayedMailer.deliver_later_queue_name + assert_equal :mailers, ActionMailer::Base.deliver_later_queue_name + + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_message", "deliver_now", args: []], queue: "throttled_mailers") do + DelayedMailer.test_message.deliver_later + end + ensure + DelayedMailer.deliver_later_queue_name = previous_queue_name + end + + test "deliver_later after accessing the message is disallowed" do + @mail.message # Load the message, which calls the mailer method. + + assert_raise RuntimeError do + @mail.deliver_later + end + end + + test "job delegates error handling to mailer" do + # Superclass not rescued by mailer's rescue_from RuntimeError + message = DelayedMailer.test_raise("StandardError") + assert_raise(StandardError) { message.deliver_later } + assert_nil DelayedMailer.last_error + assert_nil DelayedMailer.last_rescue_from_instance + + # Rescued by mailer's rescue_from RuntimeError + message = DelayedMailer.test_raise("DelayedMailerError") + assert_nothing_raised { message.deliver_later } + assert_equal "boom", DelayedMailer.last_error.message + assert_kind_of DelayedMailer, DelayedMailer.last_rescue_from_instance + end + + class DeserializationErrorFixture + include GlobalID::Identification + + def self.find(id) + raise "boom, missing find" + end + + attr_reader :id + def initialize(id = 1) + @id = id + end + + def to_global_id(options = {}) + super app: "foo" + end + end + + test "job delegates deserialization errors to mailer class" do + # Inject an argument that can't be deserialized. + message = DelayedMailer.test_message(DeserializationErrorFixture.new) + + # DeserializationError is raised, rescued, and delegated to the handler + # on the mailer class. + assert_nothing_raised { message.deliver_later } + assert_equal DelayedMailer, DelayedMailer.last_rescue_from_instance + assert_equal "Error while trying to deserialize arguments: boom, missing find", DelayedMailer.last_error.message + end + + test "allows for keyword arguments" do + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: ["DelayedMailer", "test_kwargs", "deliver_now", args: [argument: 1]]) do + message = DelayedMailer.test_kwargs(argument: 1) + message.deliver_later + end + end +end diff --git a/actionmailer/test/parameterized_test.rb b/actionmailer/test/parameterized_test.rb new file mode 100644 index 0000000000000..366327ee76be1 --- /dev/null +++ b/actionmailer/test/parameterized_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_job" +require "mailers/params_mailer" + +class ParameterizedTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + class DummyDeliveryJob < ActionMailer::MailDeliveryJob + end + + setup do + @previous_logger = ActiveJob::Base.logger + ActiveJob::Base.logger = Logger.new(nil) + + @previous_delivery_method = ActionMailer::Base.delivery_method + ActionMailer::Base.delivery_method = :test + + @mail = ParamsMailer.with(inviter: "david@basecamp.com", invitee: "jason@basecamp.com").invitation + end + + teardown do + ActiveJob::Base.logger = @previous_logger + ParamsMailer.deliveries.clear + ActionMailer::Base.delivery_method = @previous_delivery_method + end + + test "parameterized headers" do + assert_equal(["jason@basecamp.com"], @mail.to) + assert_equal(["david@basecamp.com"], @mail.from) + assert_equal("So says david@basecamp.com", @mail.body.encoded) + end + + test "degrade gracefully when .with is not called" do + @mail = ParamsMailer.invitation + + assert_nil(@mail.to) + assert_nil(@mail.from) + end + + test "enqueue the email with params" do + args = [ + "ParamsMailer", + "invitation", + "deliver_now", + params: { inviter: "david@basecamp.com", invitee: "jason@basecamp.com" }, + args: [], + ] + assert_performed_with(job: ActionMailer::MailDeliveryJob, args: args) do + @mail.deliver_later + end + end + + test "respond_to?" do + mailer = ParamsMailer.with(inviter: "david@basecamp.com", invitee: "jason@basecamp.com") + + assert_respond_to mailer, :invitation + assert_not_respond_to mailer, :anything + + invitation = mailer.method(:invitation) + assert_equal Method, invitation.class + + assert_raises(NameError) do + invitation = mailer.method(:anything) + end + end + + test "should enqueue a parameterized request with the correct delivery job" do + args = [ + "ParamsMailer", + "invitation", + "deliver_now", + params: { inviter: "david@basecamp.com", invitee: "jason@basecamp.com" }, + args: [], + ] + + with_delivery_job DummyDeliveryJob do + assert_performed_with(job: DummyDeliveryJob, args: args) do + @mail.deliver_later + end + end + end + + private + def with_delivery_job(job) + old_delivery_job = ParamsMailer.delivery_job + ParamsMailer.delivery_job = job + yield + ensure + ParamsMailer.delivery_job = old_delivery_job + end +end diff --git a/actionmailer/test/test_case_test.rb b/actionmailer/test/test_case_test.rb new file mode 100644 index 0000000000000..9897f3891bb1e --- /dev/null +++ b/actionmailer/test/test_case_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class TestTestMailer < ActionMailer::Base +end + +class ClearTestDeliveriesMixinTest < ActiveSupport::TestCase + include ActionMailer::TestCase::ClearTestDeliveries + + def before_setup + ActionMailer::Base.delivery_method, @original_delivery_method = :test, ActionMailer::Base.delivery_method + ActionMailer::Base.deliveries << "better clear me, setup" + super + end + + def after_teardown + super + assert_equal [], ActionMailer::Base.deliveries + ActionMailer::Base.delivery_method = @original_delivery_method + end + + def test_deliveries_are_cleared_on_setup_and_teardown + assert_equal [], ActionMailer::Base.deliveries + ActionMailer::Base.deliveries << "better clear me, teardown" + end +end + +class MailerDeliveriesClearingTest < ActionMailer::TestCase + def before_setup + ActionMailer::Base.deliveries << "better clear me, setup" + super + end + + def after_teardown + super + assert_equal [], ActionMailer::Base.deliveries + end + + def test_deliveries_are_cleared_on_setup_and_teardown + assert_equal [], ActionMailer::Base.deliveries + ActionMailer::Base.deliveries << "better clear me, teardown" + end +end + +class ManuallySetNameMailerTest < ActionMailer::TestCase + tests TestTestMailer + + def test_set_mailer_class_manual + assert_equal TestTestMailer, self.class.mailer_class + end +end + +class ManuallySetSymbolNameMailerTest < ActionMailer::TestCase + tests :test_test_mailer + + def test_set_mailer_class_manual_using_symbol + assert_equal TestTestMailer, self.class.mailer_class + end +end + +class ManuallySetStringNameMailerTest < ActionMailer::TestCase + tests "test_test_mailer" + + def test_set_mailer_class_manual_using_string + assert_equal TestTestMailer, self.class.mailer_class + end +end diff --git a/actionmailer/test/test_helper_test.rb b/actionmailer/test/test_helper_test.rb index dd62164176de8..de1c3861a96fe 100644 --- a/actionmailer/test/test_helper_test.rb +++ b/actionmailer/test/test_helper_test.rb @@ -1,15 +1,58 @@ -require 'abstract_unit' +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/testing/stream" class TestHelperMailer < ActionMailer::Base def test @world = "Earth" - mail :body => render(:inline => "Hello, <%= @world %>"), - :to => "test@example.com", - :from => "tester@example.com" + mail body: render(inline: "Hello, <%= @world %>"), + subject: "Hi!", + to: "test@example.com", + from: "tester@example.com" + end + + def test_args(recipient, name) + mail body: render(inline: "Hello, #{name}"), + to: recipient, + from: "tester@example.com" + end + + def test_named_args(recipient:, name:) + mail body: render(inline: "Hello, #{name}"), + to: recipient, + from: "tester@example.com" end + + def test_parameter_args + mail body: render(inline: "All is #{params[:all]}"), + to: "test@example.com", + from: "tester@example.com" + end +end + +class CustomDeliveryJob < ActionMailer::MailDeliveryJob +end + +class CustomDeliveryMailer < TestHelperMailer + self.delivery_job = CustomDeliveryJob +end + +class CustomQueueMailer < TestHelperMailer + self.deliver_later_queue_name = :custom_queue end class TestHelperMailerTest < ActionMailer::TestCase + include ActiveSupport::Testing::Stream + + setup do + @previous_deliver_later_queue_name = ActionMailer::Base.deliver_later_queue_name + end + + teardown do + ActionMailer::Base.deliver_later_queue_name = @previous_deliver_later_queue_name + end + def test_setup_sets_right_action_mailer_options assert_equal :test, ActionMailer::Base.delivery_method assert ActionMailer::Base.perform_deliveries @@ -36,10 +79,68 @@ def test_charset_is_utf_8 assert_equal "UTF-8", charset end + def test_encode + assert_equal "This is あ string", Mail::Encodings.q_value_decode(encode("This is あ string")) + end + + def test_read_fixture + assert_equal ["Welcome!"], read_fixture("welcome") + end + def test_assert_emails assert_nothing_raised do assert_emails 1 do - TestHelperMailer.test.deliver + TestHelperMailer.test.deliver_now + end + end + end + + def test_capture_emails + assert_nothing_raised do + emails = capture_emails do + TestHelperMailer.test.deliver_now + end + email = emails.first + assert_instance_of Mail::Message, email + assert_equal "Hello, Earth", email.body.to_s + assert_equal "Hi!", email.subject + + emails = capture_emails do + TestHelperMailer.test.deliver_now + TestHelperMailer.test.deliver_now + end + assert_instance_of Array, emails + assert_instance_of Mail::Message, emails.first + assert_instance_of Mail::Message, emails.second + end + end + + def test_assert_emails_with_custom_delivery_job + assert_nothing_raised do + assert_emails(1) do + silence_stream($stdout) do + CustomDeliveryMailer.test.deliver_later + end + end + end + end + + def test_assert_emails_with_custom_parameterized_delivery_job + assert_nothing_raised do + assert_emails(1) do + silence_stream($stdout) do + CustomDeliveryMailer.with(foo: "bar").test_parameter_args.deliver_later + end + end + end + end + + def test_assert_emails_with_enqueued_emails + assert_nothing_raised do + assert_emails 1 do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end end end end @@ -47,27 +148,27 @@ def test_assert_emails def test_repeated_assert_emails_calls assert_nothing_raised do assert_emails 1 do - TestHelperMailer.test.deliver + TestHelperMailer.test.deliver_now end end assert_nothing_raised do assert_emails 2 do - TestHelperMailer.test.deliver - TestHelperMailer.test.deliver + TestHelperMailer.test.deliver_now + TestHelperMailer.test.deliver_now end end end def test_assert_emails_with_no_block assert_nothing_raised do - TestHelperMailer.test.deliver + TestHelperMailer.test.deliver_now assert_emails 1 end assert_nothing_raised do - TestHelperMailer.test.deliver - TestHelperMailer.test.deliver + TestHelperMailer.test.deliver_now + TestHelperMailer.test.deliver_now assert_emails 3 end end @@ -80,10 +181,22 @@ def test_assert_no_emails end end + def test_assert_no_emails_with_enqueued_emails + error = assert_raise ActiveSupport::TestCase::Assertion do + assert_no_emails do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + + assert_match(/0 .* but 1/, error.message) + end + def test_assert_emails_too_few_sent error = assert_raise ActiveSupport::TestCase::Assertion do assert_emails 2 do - TestHelperMailer.test.deliver + TestHelperMailer.test.deliver_now end end @@ -93,23 +206,406 @@ def test_assert_emails_too_few_sent def test_assert_emails_too_many_sent error = assert_raise ActiveSupport::TestCase::Assertion do assert_emails 1 do - TestHelperMailer.test.deliver - TestHelperMailer.test.deliver + TestHelperMailer.test.deliver_now + TestHelperMailer.test.deliver_now end end assert_match(/1 .* but 2/, error.message) end + def test_assert_emails_message + TestHelperMailer.test.deliver_now + error = assert_raise ActiveSupport::TestCase::Assertion do + assert_emails 2 do + TestHelperMailer.test.deliver_now + end + end + assert_match "Expected: 2", error.message + assert_match "Actual: 1", error.message + end + def test_assert_no_emails_failure error = assert_raise ActiveSupport::TestCase::Assertion do assert_no_emails do - TestHelperMailer.test.deliver + TestHelperMailer.test.deliver_now end end assert_match(/0 .* but 1/, error.message) end + + def test_assert_enqueued_emails + assert_nothing_raised do + assert_enqueued_emails 1 do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_parameterized_emails + assert_nothing_raised do + assert_enqueued_emails 1 do + silence_stream($stdout) do + TestHelperMailer.with(a: 1).test.deliver_later + end + end + end + end + + def test_assert_enqueued_emails_too_few_sent + error = assert_raise ActiveSupport::TestCase::Assertion do + assert_enqueued_emails 2 do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + + assert_match(/2 .* but 1/, error.message) + end + + def test_assert_enqueued_emails_with_custom_delivery_job + assert_nothing_raised do + assert_enqueued_emails(1) do + silence_stream($stdout) do + CustomDeliveryMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_emails_too_many_sent + error = assert_raise ActiveSupport::TestCase::Assertion do + assert_enqueued_emails 1 do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + TestHelperMailer.test.deliver_later + end + end + end + + assert_match(/1 .* but 2/, error.message) + end + + def test_assert_no_enqueued_emails + assert_nothing_raised do + assert_no_enqueued_emails do + TestHelperMailer.test.deliver_now + end + end + end + + def test_assert_no_enqueued_parameterized_emails + assert_nothing_raised do + assert_no_enqueued_emails do + TestHelperMailer.with(a: 1).test.deliver_now + end + end + end + + def test_assert_no_enqueued_emails_failure + error = assert_raise ActiveSupport::TestCase::Assertion do + assert_no_enqueued_emails do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + + assert_match(/0 .* but 1/, error.message) + end + + def test_assert_enqueued_email_with + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_when_deliver_later_queue_name_is_nil + ActionMailer::Base.deliver_later_queue_name = nil + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_when_deliver_later_queue_name_with_non_default_name + ActionMailer::Base.deliver_later_queue_name = "sample_mailer_queue_name" + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_when_deliver_later_queue_name_is_symbol + ActionMailer::Base.deliver_later_queue_name = :mailers + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_when_queue_arg_is_symbol + ActionMailer::Base.deliver_later_queue_name = "mailers" + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test, queue: :mailers do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_when_mailer_has_custom_deliver_later_queue + assert_nothing_raised do + assert_enqueued_email_with CustomQueueMailer, :test do + silence_stream($stdout) do + CustomQueueMailer.test.deliver_later + end + end + + assert_enqueued_email_with CustomQueueMailer, :test, queue: :custom_queue do + silence_stream($stdout) do + CustomQueueMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_when_mailer_has_custom_delivery_job + assert_nothing_raised do + assert_enqueued_email_with CustomDeliveryMailer, :test do + silence_stream($stdout) do + CustomDeliveryMailer.test.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_no_block + assert_nothing_raised do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + assert_enqueued_email_with TestHelperMailer, :test + end + end + end + + def test_assert_enqueued_email_with_with_args + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_args, args: ["some_email", "some_name"] do + silence_stream($stdout) do + TestHelperMailer.test_args("some_email", "some_name").deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_no_block_with_args + assert_nothing_raised do + silence_stream($stdout) do + TestHelperMailer.test_args("some_email", "some_name").deliver_later + assert_enqueued_email_with TestHelperMailer, :test_args, args: ["some_email", "some_name"] + end + end + end + + def test_assert_enqueued_email_with_with_parameterized_args + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_parameter_args, params: { all: "good" } do + silence_stream($stdout) do + TestHelperMailer.with(all: "good").test_parameter_args.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_parameterized_mailer + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer.with(all: "good"), :test_parameter_args do + silence_stream($stdout) do + TestHelperMailer.with(all: "good").test_parameter_args.deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_named_args + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_named_args, args: [{ email: "some_email", name: "some_name" }] do + silence_stream($stdout) do + TestHelperMailer.test_named_args(email: "some_email", name: "some_name").deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_params_and_args + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_args, params: { all: "good" }, args: ["some_email", "some_name"] do + silence_stream($stdout) do + TestHelperMailer.with(all: "good").test_args("some_email", "some_name").deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_params_and_named_args + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_named_args, params: { all: "good" }, args: [{ email: "some_email", name: "some_name" }] do + silence_stream($stdout) do + TestHelperMailer.with(all: "good").test_named_args(email: "some_email", name: "some_name").deliver_later + end + end + end + end + + def test_assert_enqueued_email_with_with_no_block_with_parameterized_args + assert_nothing_raised do + silence_stream($stdout) do + TestHelperMailer.with(all: "good").test_parameter_args.deliver_later + end + assert_enqueued_email_with TestHelperMailer, :test_parameter_args, params: { all: "good" } + end + end + + def test_assert_enqueued_email_with_supports_params_matcher_proc + mail_params = { all: "good" } + + silence_stream($stdout) do + TestHelperMailer.with(mail_params).test_parameter_args.deliver_later + end + + matcher_params = nil + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_parameter_args, params: ->(params) { matcher_params = params } + end + + assert_equal mail_params, matcher_params + + assert_raises ActiveSupport::TestCase::Assertion do + assert_enqueued_email_with TestHelperMailer, :test_parameter_args, params: ->(_) { false } + end + end + + def test_assert_enqueued_email_with_supports_args_matcher_proc + mail_args = ["some_email", "some_name"] + + silence_stream($stdout) do + TestHelperMailer.test_args(*mail_args).deliver_later + end + + matcher_args = nil + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_args, args: ->(args) { matcher_args = args } + end + + assert_equal mail_args, matcher_args + + assert_raises ActiveSupport::TestCase::Assertion do + assert_enqueued_email_with TestHelperMailer, :test_args, args: ->(_) { false } + end + end + + def test_assert_enqueued_email_with_supports_named_args_matcher_proc + mail_args = [{ email: "some_email", name: "some_name" }] + + silence_stream($stdout) do + TestHelperMailer.test_named_args(**mail_args[0]).deliver_later + end + + matcher_args = nil + + assert_nothing_raised do + assert_enqueued_email_with TestHelperMailer, :test_named_args, args: ->(args) { matcher_args = args } + end + + assert_equal mail_args, matcher_args + end + + def test_deliver_enqueued_emails_with_no_block + assert_nothing_raised do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + deliver_enqueued_emails + end + end + + assert_emails(1) + end + + def test_deliver_enqueued_emails_with_a_block + assert_nothing_raised do + deliver_enqueued_emails do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + end + end + end + + assert_emails(1) + end + + def test_deliver_enqueued_emails_with_custom_delivery_job + assert_nothing_raised do + deliver_enqueued_emails do + silence_stream($stdout) do + CustomDeliveryMailer.test.deliver_later + end + end + end + + assert_emails(1) + end + + def test_deliver_enqueued_emails_with_custom_queue + assert_nothing_raised do + deliver_enqueued_emails(queue: CustomQueueMailer.deliver_later_queue_name) do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + CustomQueueMailer.test.deliver_later + end + end + end + + assert_emails(1) + assert_enqueued_email_with(TestHelperMailer, :test) + end + + def test_deliver_enqueued_emails_with_at + assert_nothing_raised do + deliver_enqueued_emails(at: 1.hour.from_now) do + silence_stream($stdout) do + TestHelperMailer.test.deliver_later + TestHelperMailer.test.deliver_later(wait: 2.hours) + end + end + end + + assert_emails(1) + end end class AnotherTestHelperMailerTest < ActionMailer::TestCase @@ -121,6 +617,20 @@ def setup def test_setup_shouldnt_conflict_with_mailer_setup assert_kind_of Mail::Message, @expected - assert_equal 'a value', @test_var + assert_equal "a value", @test_var + end +end + +class AdapterIsNotTestAdapterTest < ActionMailer::TestCase + def queue_adapter_for_test + ActiveJob::QueueAdapters::InlineAdapter.new + end + + def test_can_send_email_using_any_active_job_adapter + assert_nothing_raised do + assert_emails 1 do + TestHelperMailer.test.deliver_now + end + end end end diff --git a/actionmailer/test/test_test.rb b/actionmailer/test/test_test.rb deleted file mode 100644 index 86fd37bea6efd..0000000000000 --- a/actionmailer/test/test_test.rb +++ /dev/null @@ -1,28 +0,0 @@ -require 'abstract_unit' - -class TestTestMailer < ActionMailer::Base -end - -class CrazyNameMailerTest < ActionMailer::TestCase - tests TestTestMailer - - def test_set_mailer_class_manual - assert_equal TestTestMailer, self.class.mailer_class - end -end - -class CrazySymbolNameMailerTest < ActionMailer::TestCase - tests :test_test_mailer - - def test_set_mailer_class_manual_using_symbol - assert_equal TestTestMailer, self.class.mailer_class - end -end - -class CrazyStringNameMailerTest < ActionMailer::TestCase - tests 'test_test_mailer' - - def test_set_mailer_class_manual_using_string - assert_equal TestTestMailer, self.class.mailer_class - end -end diff --git a/actionmailer/test/url_test.rb b/actionmailer/test/url_test.rb index 0536e830984c5..123c194aacc2d 100644 --- a/actionmailer/test/url_test.rb +++ b/actionmailer/test/url_test.rb @@ -1,37 +1,63 @@ -require 'abstract_unit' -require 'action_controller' +# frozen_string_literal: true + +require "abstract_unit" +require "action_controller" class WelcomeController < ActionController::Base end AppRoutes = ActionDispatch::Routing::RouteSet.new -class ActionMailer::Base - include AppRoutes.url_helpers +AppRoutes.draw do + get "/welcome" => "foo#bar", as: "welcome" + get "/dummy_model" => "foo#baz", as: "dummy_model" + get "/welcome/greeting", to: "welcome#greeting" + get "/a/b(/:id)", to: "a#b" end class UrlTestMailer < ActionMailer::Base - default_url_options[:host] = 'www.basecamphq.com' + include AppRoutes.url_helpers + + default_url_options[:host] = "www.basecamphq.com" configure do |c| - c.assets_dir = '' # To get the tests to pass + c.assets_dir = "" # To get the tests to pass end def signed_up_with_url(http://github.com/recipient) @recipient = recipient - @welcome_url = url_for :host => "example.com", :controller => "welcome", :action => "greeting" - mail(:to => recipient, :subject => "[Signed up] Welcome #{recipient}", - :from => "system@loudthinking.com", :date => Time.local(2004, 12, 12)) + @welcome_url = url_for host: "example.com", controller: "welcome", action: "greeting" + mail(to: recipient, subject: "[Signed up] Welcome #{recipient}", + from: "system@loudthinking.com", date: Time.local(2004, 12, 12)) + end + + def exercise_url_for(options) + @options = options + @url = url_for(@options) + mail(from: "from@example.com", to: "to@example.com", subject: "subject") end end class ActionMailerUrlTest < ActionMailer::TestCase + class DummyModel + def self.model_name + Struct.new(:route_key, :name).new("dummy_model", nil) + end - def encode( text, charset="UTF-8" ) - quoted_printable( text, charset ) + def persisted? + false + end + + def model_name + self.class.model_name + end + + def to_model + self + end end - def new_mail( charset="UTF-8" ) + def new_mail(charset = "UTF-8") mail = Mail.new mail.mime_version = "1.0" if charset @@ -40,31 +66,48 @@ def new_mail( charset="UTF-8" ) mail end - def setup - set_delivery_method :test - ActionMailer::Base.perform_deliveries = true - ActionMailer::Base.deliveries.clear - ActiveSupport::Deprecation.silenced = false + def assert_url_for(expected, options, relative = false) + expected = "http://www.basecamphq.com/#{expected}" if expected.start_with?("/") && !relative + urls = UrlTestMailer.exercise_url_for(options).body.to_s.chomp.split - @recipient = 'test@localhost' + assert_equal expected, urls.first + assert_equal expected, urls.second end - def teardown - restore_delivery_method + def setup + @recipient = "test@localhost" end - def test_signed_up_with_url + def test_url_for UrlTestMailer.delivery_method = :test - AppRoutes.draw do - match ':controller(/:action(/:id))' - match '/welcome' => "foo#bar", :as => "welcome" - end + # string + assert_url_for "http://foo/", "http://foo/" + + # symbol + assert_url_for "/welcome", :welcome + + # hash + assert_url_for "/a/b/c", controller: "a", action: "b", id: "c" + assert_url_for "/a/b/c", { controller: "a", action: "b", id: "c", only_path: true }, true + + # model + assert_url_for "/dummy_model", DummyModel.new + + # class + assert_url_for "/dummy_model", DummyModel + + # array + assert_url_for "/dummy_model", [DummyModel] + end + + def test_signed_up_with_url + UrlTestMailer.delivery_method = :test expected = new_mail expected.to = @recipient expected.subject = "[Signed up] Welcome #{@recipient}" - expected.body = "Hello there,\n\nMr. #{@recipient}. Please see our greeting at http://example.com/welcome/greeting http://www.basecamphq.com/welcome\n\n\"Somelogo\"" + expected.body = "Hello there,\n\nMr. #{@recipient}. Please see our greeting at http://example.com/welcome/greeting http://www.basecamphq.com/welcome\n\n" expected.from = "system@loudthinking.com" expected.date = Time.local(2004, 12, 12) expected.content_type = "text/html" @@ -73,15 +116,15 @@ def test_signed_up_with_url assert_nothing_raised { created = UrlTestMailer.signed_up_with_url(http://github.com/@recipient) } assert_not_nil created - expected.message_id = '<123@456>' - created.message_id = '<123@456>' - assert_equal expected.encoded, created.encoded + expected.message_id = "<123@456>" + created.message_id = "<123@456>" + assert_dom_equal expected.encoded, created.encoded - assert_nothing_raised { UrlTestMailer.signed_up_with_url(http://github.com/@recipient).deliver } + assert_nothing_raised { UrlTestMailer.signed_up_with_url(http://github.com/@recipient).deliver_now } assert_not_nil ActionMailer::Base.deliveries.first delivered = ActionMailer::Base.deliveries.first - delivered.message_id = '<123@456>' - assert_equal expected.encoded, delivered.encoded + delivered.message_id = "<123@456>" + assert_dom_equal expected.encoded, delivered.encoded end end diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 96ad3a155c657..708812d618810 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,6065 +1,189 @@ -## Rails 4.0.0 (unreleased) ## +* Always return empty body for HEAD requests in `PublicExceptions` and + `DebugExceptions`. -* Forms of persisted records use always PATCH (via the `_method` hack). *fxn* + This is required by `Rack::Lint` (per RFC9110). -* For resources, both PATCH and PUT are routed to the `update` action. *fxn* + *Hartley McGuire* -* Don't ignore `force_ssl` in development. This is a change of behavior - use a `:if` condition to recreate the old behavior. +* Add comprehensive support for HTTP Cache-Control request directives according to RFC 9111. - class AccountsController < ApplicationController - force_ssl :if => :ssl_configured? + Provides a `request.cache_control_directives` object that gives access to request cache directives: - def ssl_configured? - !Rails.env.development? - end - end - - *Pat Allan* - -* Adds support for the PATCH verb: - * Request objects respond to `patch?`. - * Routes have a new `patch` method, and understand `:patch` in the - existing places where a verb is configured, like `:via`. - * New method `patch` available in functional tests. - * If `:patch` is the default verb for updates, edits are - tunneled as PATCH rather than as PUT, and routing acts accordingly. - * New method `patch_via_redirect` available in integration tests. - - *dlee* - -* Integration tests support the `OPTIONS` method. *Jeremy Kemper* - -* `expires_in` accepts a `must_revalidate` flag. If true, "must-revalidate" - is added to the Cache-Control header. *fxn* - -* Add `date_field` and `date_field_tag` helpers which render an `input[type="date"]` tag *Olek Janiszewski* - -* Adds `image_url`, `javascript_url`, `stylesheet_url`, `audio_url`, `video_url`, and `font_url` - to assets tag helper. These URL helpers will return the full path to your assets. This is useful - when you are going to reference this asset from external host. *Prem Sichanugrist* - -* Default responder will now always use your overridden block in `respond_with` to render your response. *Prem Sichanugrist* - -* Allow `value_method` and `text_method` arguments from `collection_select` and - `options_from_collection_for_select` to receive an object that responds to `:call`, - such as a `proc`, to evaluate the option in the current element context. This works - the same way with `collection_radio_buttons` and `collection_check_boxes`. - - *Carlos Antonio da Silva + Rafael Mendonça França* - -* Add `collection_check_boxes` form helper, similar to `collection_select`: - Example: - - collection_check_boxes :post, :author_ids, Author.all, :id, :name - # Outputs something like: - - - - - - - The label/check_box pairs can be customized with a block. - - *Carlos Antonio da Silva + Rafael Mendonça França* - -* Add `collection_radio_buttons` form helper, similar to `collection_select`: - Example: - - collection_radio_buttons :post, :author_id, Author.all, :id, :name - # Outputs something like: - - - - - - The label/radio_button pairs can be customized with a block. - - *Carlos Antonio da Silva + Rafael Mendonça França* - -* check_box with `:form` html5 attribute will now replicate the `:form` - attribute to the hidden field as well. *Carlos Antonio da Silva* - -* `label` form helper accepts :for => nil to not generate the attribute. *Carlos Antonio da Silva* - -* Add `:format` option to number_to_percentage *Rodrigo Flores* - -* Add `config.action_view.logger` to configure logger for ActionView. *Rafael Mendonça França* - -* Deprecated ActionController::Integration in favour of ActionDispatch::Integration - -* Deprecated ActionController::IntegrationTest in favour of ActionDispatch::IntegrationTest - -* Deprecated ActionController::PerformanceTest in favour of ActionDispatch::PerformanceTest - -* Deprecated ActionController::AbstractRequest in favour of ActionDispatch::Request - -* Deprecated ActionController::Request in favour of ActionDispatch::Request - -* Deprecated ActionController::AbstractResponse in favour of ActionDispatch::Response - -* Deprecated ActionController::Response in favour of ActionDispatch::Response - -* Deprecated ActionController::Routing in favour of ActionDispatch::Routing - -* check_box helper with :disabled => true will generate a disabled hidden field to conform with the HTML convention where disabled fields are not submitted with the form. - This is a behavior change, previously the hidden tag had a value of the disabled checkbox. - *Tadas Tamosauskas* - -* `favicon_link_tag` helper will now use the favicon in app/assets by default. *Lucas Caton* - -* `ActionView::Helpers::TextHelper#highlight` now defaults to the - HTML5 `mark` element. *Brian Cardarella* - - -## Rails 3.2.2 (March 1, 2012) ## - -* Format lookup for partials is derived from the format in which the template is being rendered. Closes #5025 part 2 *Santiago Pastorino* - -* Use the right format when a partial is missing. Closes #5025. *Santiago Pastorino* - -* Default responder will now always use your overridden block in `respond_with` to render your response. *Prem Sichanugrist* - -* check_box helper with :disabled => true will generate a disabled hidden field to conform with the HTML convention where disabled fields are not submitted with the form. - This is a behavior change, previously the hidden tag had a value of the disabled checkbox. - *Tadas Tamosauskas* - - -## Rails 3.2.1 (January 26, 2012) ## - -* Documentation improvements. - -* Allow `form.select` to accept ranges (regression). *Jeremy Walker* - -* `datetime_select` works with -/+ infinity dates. *Joe Van Dyk* - - -## Rails 3.2.0 (January 20, 2012) ## - -* Add `config.action_dispatch.default_charset` to configure default charset for ActionDispatch::Response. *Carlos Antonio da Silva* - -* Deprecate setting default charset at controller level, use the new `config.action_dispatch.default_charset` instead. *Carlos Antonio da Silva* - -* Deprecate ActionController::UnknownAction in favour of AbstractController::ActionNotFound. *Carlos Antonio da Silva* - -* Deprecate ActionController::DoubleRenderError in favour of AbstractController::DoubleRenderError. *Carlos Antonio da Silva* - -* Deprecate method_missing handling for not found actions, use action_missing instead. *Carlos Antonio da Silva* - -* Deprecate ActionController#rescue_action, ActionController#initialize_template_class, and ActionController#assign_shortcuts. - These methods were not being used internally anymore and are going to be removed in Rails 4. *Carlos Antonio da Silva* - -* Use a BodyProxy instead of including a Module that responds to - close. Closes #4441 if Active Record is disabled assets are delivered - correctly *Santiago Pastorino* - -* Rails initialization with initialize_on_precompile = false should set assets_dir *Santiago Pastorino* - -* Add font_path helper method *Santiago Pastorino* - -* Depends on rack ~> 1.4.0 *Santiago Pastorino* - -* Add :gzip option to `caches_page`. The default option can be configured globally using `page_cache_compression` *Andrey Sitnik* - -* The ShowExceptions middleware now accepts a exceptions application that is responsible to render an exception when the application fails. The application is invoked with a copy of the exception in `env["action_dispatch.exception"]` and with the PATH_INFO rewritten to the status code. *José Valim* - -* Add `button_tag` support to ActionView::Helpers::FormBuilder. - - This support mimics the default behavior of `submit_tag`. - - Example: - - <%= form_for @post do |f| %> - <%= f.button %> - <% end %> - -* Date helpers accept a new option, `:use_two_digit_numbers = true`, that renders select boxes for months and days with a leading zero without changing the respective values. - For example, this is useful for displaying ISO8601-style dates such as '2011-08-01'. *Lennart Fridén and Kim Persson* - -* Make ActiveSupport::Benchmarkable a default module for ActionController::Base, so the #benchmark method is once again available in the controller context like it used to be *DHH* - -* Deprecated implied layout lookup in controllers whose parent had a explicit layout set: - - class ApplicationController - layout "application" - end - - class PostsController < ApplicationController - end - - In the example above, Posts controller will no longer automatically look up for a posts layout. - - If you need this functionality you could either remove `layout "application"` from ApplicationController or explicitly set it to nil in PostsController. *José Valim* - -* Rails will now use your default layout (such as "layouts/application") when you specify a layout with `:only` and `:except` condition, and those conditions fail. *Prem Sichanugrist* - - For example, consider this snippet: - - class CarsController - layout 'single_car', :only => :show - end - - Rails will use 'layouts/single_car' when a request comes in `:show` action, and use 'layouts/application' (or 'layouts/cars', if exists) when a request comes in for any other actions. - -* form_for with +:as+ option uses "#{action}_#{as}" as css class and id: - - Before: - - form_for(@user, :as => 'client') # => "
..." - - Now: - - form_for(@user, :as => 'client') # => "..." - - *Vasiliy Ermolovich* - -* Allow rescue responses to be configured through a railtie as in `config.action_dispatch.rescue_responses`. Please look at ActiveRecord::Railtie for an example *José Valim* - -* Allow fresh_when/stale? to take a record instead of an options hash *DHH* - -* Assets should use the request protocol by default or default to relative if no request is available *Jonathan del Strother* - -* Log "Filter chain halted as CALLBACKNAME rendered or redirected" every time a before callback halts *José Valim* - -* You can provide a namespace for your form to ensure uniqueness of id attributes on form elements. - The namespace attribute will be prefixed with underscore on the generate HTML id. *Vasiliy Ermolovich* - - Example: - - <%= form_for(@offer, :namespace => 'namespace') do |f| %> - <%= f.label :version, 'Version' %>: - <%= f.text_field :version %> - <% end %> - -* Refactor ActionDispatch::ShowExceptions. The controller is responsible for choosing to show exceptions when `consider_all_requests_local` is false. - - It's possible to override `show_detailed_exceptions?` in controllers to specify which requests should provide debugging information on errors. The default value is now false, meaning local requests in production will no longer show the detailed exceptions page unless `show_detailed_exceptions?` is overridden and set to `request.local?`. - -* Responders now return 204 No Content for API requests without a response body (as in the new scaffold) *José Valim* - -* Added ActionDispatch::RequestId middleware that'll make a unique X-Request-Id header available to the response and enables the ActionDispatch::Request#uuid method. This makes it easy to trace requests from end-to-end in the stack and to identify individual requests in mixed logs like Syslog *DHH* - -* Limit the number of options for select_year to 1000. - - Pass the :max_years_allowed option to set your own limit. - - *Libo Cannici* - -* Passing formats or handlers to render :template and friends is deprecated. For example: *Nick Sutterer & José Valim* - - render :template => "foo.html.erb" - - Instead, you can provide :handlers and :formats directly as option: - render :template => "foo", :formats => [:html, :js], :handlers => :erb - -* Changed log level of warning for missing CSRF token from :debug to :warn. *Mike Dillon* - -* content_tag_for and div_for can now take the collection of records. It will also yield the record as the first argument if you set a receiving argument in your block *Prem Sichanugrist* - - So instead of having to do this: - - @items.each do |item| - content_tag_for(:li, item) do - Title: <%= item.title %> - end - end - - You can now do this: - - content_tag_for(:li, @items) do |item| - Title: <%= item.title %> - end - -* send_file now guess the mime type *Esad Hajdarevic* - -* Mime type entries for PDF, ZIP and other formats were added *Esad Hajdarevic* - -* Generate hidden input before select with :multiple option set to true. - This is useful when you rely on the fact that when no options is set, - the state of select will be sent to rails application. Without hidden field - nothing is sent according to HTML spec *Bogdan Gusiev* - -* Refactor ActionController::TestCase cookies *Andrew White* - - Assigning cookies for test cases should now use cookies[], e.g: - - cookies[:email] = 'user@example.com' - get :index - assert_equal 'user@example.com', cookies[:email] - - To clear the cookies, use clear, e.g: - - cookies.clear - get :index - assert_nil cookies[:email] - - We now no longer write out HTTP_COOKIE and the cookie jar is - persistent between requests so if you need to manipulate the environment - for your test you need to do it before the cookie jar is created. - -* ActionController::ParamsWrapper on ActiveRecord models now only wrap - attr_accessible attributes if they were set, if not, only the attributes - returned by the class method attribute_names will be wrapped. This fixes - the wrapping of nested attributes by adding them to attr_accessible. - -## Rails 3.1.4 (March 1, 2012) ## - -* Skip assets group in Gemfile and all assets configurations options - when the application is generated with --skip-sprockets option. - - *Guillermo Iguaran* - -* Use ProcessedAsset#pathname in Sprockets helpers when debugging is on. Closes #3333 #3348 #3361. - - *Guillermo Iguaran* - -* Allow to use asset_path on named_routes aliasing RailsHelper's - asset_path to path_to_asset *Adrian Pike* - -* Assets should use the request protocol by default or default to relative if no request is available *Jonathan del Strother* - -## Rails 3.1.3 (November 20, 2011) ## - -* Downgrade sprockets to ~> 2.0.3. Using 2.1.0 caused regressions. + ```ruby + # Boolean directives + request.cache_control_directives.only_if_cached? # => true/false + request.cache_control_directives.no_cache? # => true/false + request.cache_control_directives.no_store? # => true/false + request.cache_control_directives.no_transform? # => true/false -* Fix using `translate` helper with a html translation which uses the `:count` option for - pluralization. + # Value directives + request.cache_control_directives.max_age # => integer or nil + request.cache_control_directives.max_stale # => integer or nil (or true for valueless max-stale) + request.cache_control_directives.min_fresh # => integer or nil + request.cache_control_directives.stale_if_error # => integer or nil - *Jon Leighton* + # Special helpers for max-stale + request.cache_control_directives.max_stale? # => true if max-stale present (with or without value) + request.cache_control_directives.max_stale_unlimited? # => true only for valueless max-stale + ``` -## Rails 3.1.2 (November 18, 2011) ## + Example usage: -* Fix XSS security vulnerability in the `translate` helper method. When using interpolation - in combination with HTML-safe translations, the interpolated input would not get HTML - escaped. *GH 3664* + ```ruby + def show + if request.cache_control_directives.only_if_cached? + @article = Article.find_cached(params[:id]) + return head(:gateway_timeout) if @article.nil? + else + @article = Article.find(params[:id]) + end - Before: - - translate('foo_html', :something => ' - - Which is needed for dealing with the IE6 DOM when it's not yet fully loaded. - -* Fixed that rescue template path shouldn't be hardcoded, then it's easier to hook in your own #6295 *Mike Naberezny* - -* Fixed escaping of backslashes in JavaScriptHelper#escape_javascript #6302 *sven@c3d2.de* - -* Fixed that some 500 rescues would cause 500's themselves because the response had not yet been generated #6329 *cmselmer* - -* respond_to :html doesn't assume .rhtml. #6281 *Hampton Catlin* - -* Fixed some deprecation warnings in ActionPack *Rick Olson* - -* assert_select_rjs decodes escaped unicode chars since the Javascript generators encode them. #6240 *japgolly* - -* Deprecation: @cookies, @headers, @request, @response will be removed after 1.2. Use the corresponding method instead. *Jeremy Kemper* - -* Make the :status parameter expand to the default message for that status code if it is an integer. Also support symbol statuses. *Jamis Buck*. Examples: - - head :status => 404 # expands to "404 Not Found" - head :status => :not_found # expands to "404 Not Found" - head :status => :created # expands to "201 Created" - -* Add head(options = {}) for responses that have no body. *Jamis Buck*. Examples: - - head :status => 404 # return an empty response with a 404 status - head :location => person_path(@person), :status => 201 - -* Fix bug that kept any before_filter except the first one from being able to halt the before_filter chain. *Rick Olson* - -* strip_links is case-insensitive. #6285 *tagoh, Bob Silva* - -* Clear the cache of possible controllers whenever Routes are reloaded. *Nicholas Seckar* - -* Filters overhaul including meantime filter support using around filters + blocks. #5949 *Martin Emde, Roman Le Negrate, Stefan Kaes, Jeremy Kemper* - -* Update RJS render tests. *sam* - -* Update CGI process to allow sessions to contain namespaced models. Closes #4638. *dfelstead@site5.com* - -* Fix routing to respect user provided requirements and defaults when assigning default routing options (such as :action => 'index'). Closes #5950. *Nicholas Seckar* - -* Rescue Errno::ECONNRESET to handle an unexpectedly closed socket connection. Improves SCGI reliability. #3368, #6226 *sdsykes, fhanshaw@vesaria.com* - -* Added that respond_to blocks will automatically set the content type to be the same as is requested *DHH*. Examples: - - respond_to do |format| - format.html { render :text => "I'm being sent as text/html" } - format.rss { render :text => "I'm being sent as application/rss+xml" } - format.atom { render :text => "I'm being sent as application/xml", :content_type => Mime::XML } - end - -* Added utf-8 as the default charset for all renders. You can change this default using ActionController::Base.default_charset=(encoding) *David Heinemeier Hansson* - -* Added proper getters and setters for content type and charset *DHH*. Example of what we used to do: - - response.headers["Content-Type"] = "application/atom+xml; charset=utf-8" - - ...now: - - response.content_type = Mime::ATOM - response.charset = "utf-8" - -* Updated prototype.js to 1.5.0_rc1 with latest fixes. *Rick Olson* - - - XPATH support - - Make Form.getElements() return elements in the correct order - - fix broken Form.serialize return - -* Declare file extensions exempt from layouts. #6219 *brandon* - Example: ActionController::Base.exempt_from_layout 'rpdf' - -* Add chained replace/update support for assert_select_rjs *Rick Olson* - - Given RJS like... - - page['test1'].replace "
foo
" - page['test2'].replace_html "
foo
" - - Test it with... - - assert_select_rjs :chained_replace - assert_select_rjs :chained_replace, "test1" - - assert_select_rjs :chained_replace_html - assert_select_rjs :chained_replace_html, "test2" - -* Load helpers in alphabetical order for consistency. Resolve cyclic javascript_helper dependency. #6132, #6178 *choonkeat@gmail.com* - -* Skip params with empty names, such as the &=Save query string from . #2569 *Manfred Stienstra, raphinou@yahoo.com* - -* Fix assert_tag so that :content => "foo" does not match substrings, but only exact strings. Use :content => /foo/ to match substrings. #2799 *Eric Hodel* - -* Add descriptive messages to the exceptions thrown by cgi_methods. #6091, #6103 *Nicholas Seckar, Bob Silva* - -* Update JavaScriptGenerator#show/hide/toggle/remove to new Prototype syntax for multiple ids, #6068 *petermichaux@gmail.com* - -* Update UrlWriter to support :only_path. *Nicholas Seckar, Dave Thomas* - -* Fixed JavaScriptHelper#link_to_function and JavaScriptHelper#button_to_function to have the script argument be optional *DHH*. So what used to require a nil, like this: - - link_to("Hider", nil, :class => "hider_link") { |p| p[:something].hide } - - ...can be written like this: - - link_to("Hider", :class => "hider_link") { |p| p[:something].hide } - -* Update to script.aculo.us 1.6.3 *Thomas Fuchs* - -* Update to Prototype 1.5.0_rc1 *sam* - -* Added access to nested attributes in RJS #4548 *richcollins@gmail.com*. Examples: - - page['foo']['style'] # => $('foo').style; - page['foo']['style']['color'] # => $('blank_slate').style.color; - page['foo']['style']['color'] = 'red' # => $('blank_slate').style.color = 'red'; - page['foo']['style'].color = 'red' # => $('blank_slate').style.color = 'red'; - -* Fixed that AssetTagHelper#image_tag and others using compute_public_path should not modify the incoming source argument (closes #5102) *eule@space.ch* - -* Deprecated the auto-appending of .png to AssetTagHelper#image_tag calls that doesn't have an extension *David Heinemeier Hansson* - -* Fixed FormOptionsHelper#select to respect :selected value #5813 - -* Fixed TextHelper#simple_format to deal with multiple single returns within a single paragraph #5835 *moriq@moriq.com* - -* Fixed TextHelper#pluralize to handle 1 as a string #5909 *rails@bencurtis.com* - -* Improved resolution of DateHelper#distance_of_time_in_words for better precision #5994 *Bob Silva* - -* Changed that uncaught exceptions raised any where in the application will cause RAILS_ROOT/public/500.html to be read and shown instead of just the static "Application error (Rails)" *David Heinemeier Hansson* - -* Integration tests: thoroughly test ActionController::Integration::Session. #6022 *Kevin Clark* - (tests skipped unless you `gem install mocha`) - -* Added deprecation language for pagination which will become a plugin by Rails 2.0 *David Heinemeier Hansson* - -* Added deprecation language for in_place_editor and auto_complete_field that both pieces will become plugins by Rails 2.0 *David Heinemeier Hansson* - -* Deprecated all of ActionController::Dependencies. All dependency loading is now handled from Active Support *David Heinemeier Hansson* - -* Added assert_select* for CSS selector-based testing (deprecates assert_tag) #5936 *assaf.arkin@gmail.com* - -* radio_button_tag generates unique id attributes. #3353 *Bob Silva, somekool@gmail.com* - -* strip_tags passes through blank args such as nil or "". #2229, #6702 *duncan@whomwah.com, dharana* - -* Cleanup assert_tag :children counting. #2181 *jamie@bravenet.com* - -* button_to accepts :method so you can PUT and DELETE with it. #6005 *Dan Webb* - -* Update sanitize text helper to strip plaintext tags, and . *Rick Olson* - -* Update routing documentation. Closes #6017 *Nathan Witmer* - -* Add routing tests to assert that RoutingError is raised when conditions aren't met. Closes #6016 *Nathan Witmer* - -* Deprecation: update docs. #5998 *Jakob Skjerning, Kevin Clark* - -* Make auto_link parse a greater subset of valid url formats. *Jamis Buck* - -* Integration tests: headers beginning with X aren't excluded from the HTTP_ prefix, so X-Requested-With becomes HTTP_X_REQUESTED_WITH as expected. *Mike Clark* - -* Tighten rescue clauses. #5985 *james@grayproductions.net* - -* Fix send_data documentation typo. #5982 *brad@madriska.com* - -* Switch to using FormEncodedPairParser for parsing request parameters. *Nicholas Seckar, David Heinemeier Hansson* - -* respond_to .html now always renders #{action_name}.rhtml so that registered custom template handlers do not override it in priority. Custom mime types require a block and throw proper error now. *Tobias Lütke* - -* Deprecation: test deprecated instance vars in partials. *Jeremy Kemper* - -* Add UrlWriter to allow writing urls from Mailers and scripts. *Nicholas Seckar* - -* Clean up and run the Active Record integration tests by default. #5854 *Kevin Clark, Jeremy Kemper* - -* Correct example in cookies docs. #5832 *jessemerriman@warpmail.net* - -* Updated to script.aculo.us 1.6.2 *Thomas Fuchs* - -* Relax Routing's anchor pattern warning; it was preventing use of [^/] inside restrictions. *Nicholas Seckar* - -* Add controller_paths variable to Routing. *Nicholas Seckar* - -* Fix assert_redirected_to issue with named routes for module controllers. *Rick Olson* - -* Tweak RoutingError message to show option diffs, not just missing named route significant keys. *Rick Olson* - -* Invoke method_missing directly on hidden actions. Closes #3030. *Nicholas Seckar* - -* Require Tempfile explicitly for TestUploadedFile due to changes in class auto loading. *Rick Olson* - -* Add RoutingError exception when RouteSet fails to generate a path from a Named Route. *Rick Olson* - -* Replace Reloadable with Reloadable::Deprecated. *Nicholas Seckar* - -* Deprecation: check whether instance variables have been monkeyed with before assigning them to deprecation proxies. Raises a RuntimeError if so. *Jeremy Kemper* - -* Add support for the param_name parameter to the auto_complete_field helper. #5026 *david.a.williams@gmail.com* - -* Deprecation! @params, @session, @flash will be removed after 1.2. Use the corresponding instance methods instead. You'll get printed warnings during tests and logged warnings in dev mode when you access either instance variable directly. *Jeremy Kemper* - -* Make Routing noisy when an anchor regexp is assigned to a segment. #5674 *François Beausoleil* - -* Added months and years to the resolution of DateHelper#distance_of_time_in_words, such that "60 days ago" becomes "2 months ago" #5611 *pjhyett@gmail.com* - -* Short documentation to mention use of Mime::Type.register. #5710 *choonkeat@gmail.com* - -* Make controller_path available as an instance method. #5724 *jmckible@gmail.com* - -* Update query parser to support adjacent hashes. *Nicholas Seckar* - -* Make action caching aware of different formats for the same action so that, e.g. foo.xml is cached separately from foo.html. Implicitly set content type when reading in cached content with mime revealing extensions so the entire onous isn't on the webserver. *Marcel Molina Jr.* - -* Restrict Request Method hacking with ?_method to POST requests. *Rick Olson* - -* Fix bug when passing multiple options to SimplyRestful, like :new => { :preview => :get, :draft => :get }. *Rick Olson, Josh Susser, Lars Pind* - -* Dup the options passed to map.resources so that multiple resources get the same options. *Rick Olson* - -* Fixed the new_#{resource}_url route and added named route tests for Simply Restful. *Rick Olson* - -* Added map.resources from the Simply Restful plugin *DHH*. Examples (the API has changed to use plurals!): - - map.resources :messages - map.resources :messages, :comments - map.resources :messages, :new => { :preview => :post } - -* Fixed that integration simulation of XHRs should set Accept header as well *Edward Frederick* - -* TestRequest#reset_session should restore a TestSession, not a hash *Michael Koziarski* - -* Don't search a load-path of '.' for controller files *Jamis Buck* - -* Update integration.rb to require test_process explicitly instead of via Dependencies. *Nicholas Seckar* - -* Fixed that you can still access the flash after the flash has been reset in reset_session. Closes #5584 *lmarlow* - -* Allow form_for and fields_for to work with indexed form inputs. *Jeremy Kemper, Matt Lyon* - - <% form_for 'post[]', @post do |f| -%> - <% end -%> - -* Remove leak in development mode by replacing define_method with module_eval. *Nicholas Seckar* - -* Provide support for decimal columns to form helpers. Closes #5672. *Dave Thomas* - -* Update documentation for erb trim syntax. #5651 *matt@mattmargolis.net* - -* Pass :id => nil or :class => nil to error_messages_for to supress that html attribute. #3586 *olivier_ansaldi@yahoo.com* - -* Reset @html_document between requests so assert_tag works. #4810 *Jarkko Laine, easleydp@gmail.com* - -* Update render :partial documentation. #5646 *matt@mattmargolis.net* - -* Integration tests behave well with render_component. #4632 *edward.frederick@revolution.com, dev.rubyonrails@maxdunn.com* - -* Added exception handling of missing layouts #5373 *chris@ozmm.org* - -* Fixed that real files and symlinks should be treated the same when compiling templates #5438 *zachary@panandscan.com* - -* Fixed that the flash should be reset when reset_session is called #5584 *Shugo Maeda* - -* Added special case for "1 Byte" in NumberHelper#number_to_human_size #5593 *murpyh@rubychan.de* - -* Fixed proper form-encoded parameter parsing for requests with "Content-Type: application/x-www-form-urlencoded; charset=utf-8" (note the presence of a charset directive) *David Heinemeier Hansson* - -* Add route_name_path method to generate only the path for a named routes. For example, map.person will add person_path. *Nicholas Seckar* - -* Avoid naming collision among compiled view methods. *Jeremy Kemper* - -* Fix CGI extensions when they expect string but get nil in Windows. Closes #5276 *Mislav Marohnić* - -* Determine the correct template_root for deeply nested components. #2841 *s.brink@web.de* - -* Fix that routes with *path segments in the recall can generate URLs. *Rick Olson* - -* Fix strip_links so that it doesn't hang on multiline tags *Jamis Buck* - -* Remove problematic control chars in rescue template. #5316 *Stefan Kaes* - -* Make sure passed routing options are not mutated by routing code. #5314 *Blair Zajac* - -* Make sure changing the controller from foo/bar to bing/bang does not change relative to foo. *Jamis Buck* - -* Escape the path before routing recognition. #3671 - -* Make sure :id and friends are unescaped properly. #5275 *me@julik.nl* - -* Fix documentation for with_routing to reflect new reality. #5281 *rramdas@gmail.com* - -* Rewind readable CGI params so others may reread them (such as CGI::Session when passing the session id in a multipart form). #210 *mklame@atxeu.com, matthew@walker.wattle.id.au* - -* Added Mime::TEXT (text/plain) and Mime::ICS (text/calendar) as new default types *David Heinemeier Hansson* - -* Added Mime::Type.register(string, symbol, synonyms = []) for adding new custom mime types *DHH*. Example: Mime::Type.register("image/gif", :gif) - -* Added support for Mime objects in render :content_type option *DHH*. Example: render :text => some_atom, :content_type => Mime::ATOM - -* Add :status option to send_data and send_file. Defaults to '200 OK'. #5243 *Manfred Stienstra * - -* Routing rewrite. Simpler, faster, easier to understand. The published API for config/routes.rb is unchanged, but nearly everything else is different, so expect breakage in plugins and libs that try to fiddle with routes. *Nicholas Seckar, Jamis Buck* - - map.connect '/foo/:id', :controller => '...', :action => '...' - map.connect '/foo/:id.:format', :controller => '...', :action => '...' - map.connect '/foo/:id', ..., :conditions => { :method => :get } - -* Cope with missing content type and length headers. Parse parameters from multipart and urlencoded request bodies only. *Jeremy Kemper* - -* Accept multipart PUT parameters. #5235 *guy.naor@famundo.com* - -* Added interrogation of params[:format] to determine Accept type. If :format is specified and matches a declared extension, like "rss" or "xml", that mime type will be put in front of the accept handler. This means you can link to the same action from different extensions and use that fact to determine output *DHH*. Example: - - class WeblogController < ActionController::Base - def index - @posts = Post.find :all - - respond_to do |format| - format.html - format.xml { render :xml => @posts.to_xml } - format.rss { render :action => "feed.rxml" } - end - end - end - - \# returns HTML when requested by a browser, since the browser - \# has the HTML mimetype at the top of its priority list - Accept: text/html - GET /weblog - - \# returns the XML - Accept: application/xml - GET /weblog - - \# returns the HTML - Accept: application/xml - GET /weblog.html - - \# returns the XML - Accept: text/html - GET /weblog.xml - - All this relies on the fact that you have a route that includes .:format. - -* Expanded :method option in FormTagHelper#form_tag, FormHelper#form_for, PrototypeHelper#remote_form_for, PrototypeHelper#remote_form_tag, and PrototypeHelper#link_to_remote to allow for verbs other than GET and POST by automatically creating a hidden form field named _method, which will simulate the other verbs over post *David Heinemeier Hansson* - -* Added :method option to UrlHelper#link_to, which allows for using other verbs than GET for the link. This replaces the :post option, which is now deprecated. Example: link_to "Destroy", person_url(http://github.com/:id%20=%3E%20person), :method => :delete *David Heinemeier Hansson* - -* follow_redirect doesn't complain about being redirected to the same controller. #5153 *dymo@mk.ukrtelecom.ua* - -* Add layout attribute to response object with the name of the layout that was rendered, or nil if none rendered. *Kevin Clark* - -* Fix NoMethodError when parsing params like &&. *Adam Greenfield* - -* Fix flip flopped logic in docs for url_for's :only_path option. Closes #4998. *esad@esse.at* - -* form.text_area handles the :size option just like the original text_area (:size => '60x10' becomes cols="60" rows="10"). *Jeremy Kemper* - -* Excise ingrown code from FormOptionsHelper#options_for_select. #5008 *anonymous* - -* Small fix in routing to allow dynamic routes (broken after [4242]) *Rick Olson* - - map.connect '*path', :controller => 'files', :action => 'show' - -* Replace alias method chaining with Module#alias_method_chain. *Marcel Molina Jr.* - -* Replace Ruby's deprecated append_features in favor of included. *Marcel Molina Jr.* - -* Use #flush between switching from #write to #syswrite. Closes #4907. *Blair Zajac * - -* Documentation fix: integration test scripts don't require integration_test. Closes #4914. *Frederick Ros * - -* ActionController::Base Summary documentation rewrite. Closes #4900. *Kevin Clark* - -* Fix text_helper.rb documentation rendering. Closes #4725. *Frederick Ros* - -* Fixes bad rendering of JavaScriptMacrosHelper rdoc (closes #4910) *Frederick Ros* - -* Allow error_messages_for to report errors for multiple objects, as well as support for customizing the name of the object in the error summary header. Closes #4186. *andrew@redlinesoftware.com, Marcel Molina Jr.* - - error_messages_for :account, :user, :subscription, :object_name => :account - -* Enhance documentation for setting headers in integration tests. Skip auto HTTP prepending when its already there. Closes #4079. *Rick Olson* - -* Documentation for AbstractRequest. Closes #4895. *Kevin Clark* - -* Refactor various InstanceTag instance method to class methods. Closes #4800. *Stefan Kaes* - -* Remove all remaining references to @params in the documentation. *Marcel Molina Jr.* - -* Add documentation for redirect_to :back's RedirectBackError exception. *Marcel Molina Jr.* - -* Update layout and content_for documentation to use yield rather than magic @content_for instance variables. *Marcel Molina Jr.* - -* Fix assert_redirected_to tests according to real-world usage. Also, don't fail if you add an extra :controller option: *Rick Olson* - - redirect_to :action => 'new' - assert_redirected_to :controller => 'monkeys', :action => 'new' - -* Cache CgiRequest#request_parameters so that multiple calls don't re-parse multipart data. *Rick Olson* - -* Diff compared routing options. Allow #assert_recognizes to take a second arg as a hash to specify optional request method *Rick Olson* - - assert_recognizes({:controller => 'users', :action => 'index'}, 'users') - assert_recognizes({:controller => 'users', :action => 'create'}, {:path => 'users', :method => :post}) - -* Diff compared options with #assert_redirected_to *Rick Olson* - -* Add support in routes for semicolon delimited "subpaths", like /books/:id;:action *Jamis Buck* - -* Change link_to_function and button_to_function to (optionally) take an update_page block instead of a JavaScript string. Closes #4804. *zraii@comcast.net, Sam Stephenson* - -* Fixed that remote_form_for can leave out the object parameter and default to the instance variable of the object_name, just like form_for *David Heinemeier Hansson* - -* Modify routing so that you can say :require => { :method => :post } for a route, and the route will never be selected unless the request method is POST. Only works for route recognition, not for route generation. *Jamis Buck* - -* Added :add_headers option to verify which merges a hash of name/value pairs into the response's headers hash if the prerequisites cannot be satisfied. *Sam Stephenson* - ex. verify :only => :speak, :method => :post, - :render => { :status => 405, :text => "Must be post" }, - :add_headers => { "Allow" => "POST" } - -* Added ActionController.filter_parameter_logging that makes it easy to remove passwords, credit card numbers, and other sensitive information from being logged when a request is handled #1897 *jeremye@bsa.ca.gov* - - -## 1.13.3 (March 12th, 2007) ## - -* Apply [5709] to stable. - -* session_enabled? works with session :off. #6680 *Jonathan del Strother* - -* Performance: patch cgi/session to require digest/md5 once rather than per #create_new_id. *Stefan Kaes* - - -## 1.13.2 (February 5th, 2007) ## - -* Add much-needed html-scanner tests. Fixed CDATA parsing bug. *Rick Olson* - -* improve error message for Routing for named routes. *Rob Sanheim* - -* Added enhanced docs to routing assertions. *Rob Sanheim* - -* fix form_for example in ActionController::Resources documentation. *gnarg* - -* Add singleton resources from trunk *Rick Olson* - -* select :multiple => true suffixes the attribute name with [] unless already suffixed. #6977 *nik.kakelin, ben, julik* - -* Improve routes documentation. #7095 *zackchandler* - -* Resource member routes require :id, eliminating the ambiguous overlap with collection routes. #7229 *dkubb* - -* Fixed NumberHelper#number_with_delimiter to use "." always for splitting the original number, not the delimiter parameter #7389 *ceefour* - -* Autolinking recognizes trailing and embedded . , : ; #7354 *Jarkko Laine* - -* Make TextHelper::auto_link recognize URLs with colons in path correctly, fixes #7268. *imajes* - -* Improved auto_link to match more valid urls correctly *Tobias Lütke* - - -## 1.13.1 (January 18th, 2007) ## - -* Fixed content-type bug in Prototype *sam* - - -## 1.13.0 (January 16th, 2007) ## - -* Modernize cookie testing code, and increase coverage (Heckle++) #7101 *Kevin Clark* - -* Heckling ActionController::Resources::Resource revealed that set_prefixes didn't break when :name_prefix was munged. #7081 *Kevin Clark* - -* Update to Prototype 1.5.0. *Sam Stephenson* - -* Allow exempt_from_layout :rhtml. #6742, #7026 *Dan Manges, Squeegy* - -* Fix parsing of array[] CGI parameters so extra empty values aren't included. #6252 *Nicholas Seckar, aiwilliams, brentrowland* - -* link_to_unless_current works with full URLs as well as paths. #6891 *Jarkko Laine, Manfred Stienstra, idrifter* - -* Fix HTML::Node to output double quotes instead of single quotes. Closes #6845 *mitreandy* - -* Fix no method error with error_messages_on. Closes #6935 *nik.wakelin Koz* - -* Slight doc tweak to the ActionView::Helpers::PrototypeHelper#replace docs. Closes #6922 *Steven Bristol* - -* Slight doc tweak to #prepend_filter. Closes #6493 *Jeremy Voorhis* - -* Add more extensive documentation to the AssetTagHelper. Closes #6452 *Bob Silva* - -* Clean up multiple calls to #stringify_keys in TagHelper, add better documentation and testing for TagHelper. Closes #6394 *Bob Silva* - -* [DOCS] fix reference to ActionController::Macros::AutoComplete for #text_field_with_auto_complete. Closes #2578 *Jan Prill* - -* Make sure html_document is reset between integration test requests. *ctm* - -* Set session to an empty hash if :new_session => false and no session cookie or param is present. CGI::Session was raising an unrescued ArgumentError. *Josh Susser* - -* Fix assert_redirected_to bug where redirecting from a nested to to a top-level controller incorrectly added the current controller's nesting. Closes #6128. *Rick Olson* - -* Ensure render :json => ... skips the layout. #6808 *Josh Peek* - -* Silence log_error deprecation warnings from inspecting deprecated instance variables. *Nate Wiger* - -* Only cache GET requests with a 200 OK response. #6514, #6743 *RSL, anamba* - -* Correctly report which filter halted the chain. #6699 *Martin Emde* - -* respond_to recognizes JSON. render :json => @person.to_json automatically sets the content type and takes a :callback option to specify a client-side function to call using the rendered JSON as an argument. #4185 *Scott Raymond, eventualbuddha* - # application/json response with body 'Element.show({:name: "David"})' - respond_to do |format| - format.json { render :json => { :name => "David" }.to_json, :callback => 'Element.show' } - end - -* Makes :discard_year work without breaking multi-attribute parsing in AR. #1260, #3800 *sean@ardismg.com, jmartin@desertflood.com, stephen@touset.org, Bob Silva* - -* Adds html id attribute to date helper elements. #1050, #1382 *mortonda@dgrmm.net, David North, Bob Silva* - -* Add :index and @auto_index capability to model driven date/time selects. #847, #2655 *moriq, Doug Fales, Bob Silva* - -* Add :order to datetime_select, select_datetime, and select_date. #1427 *Timothee Peignier, Patrick Lenz, Bob Silva* - -* Added time_select to work with time values in models. Update scaffolding. #2489, #2833 *Justin Palmer, Andre Caum, Bob Silva* - -* Added :include_seconds to select_datetime, datetime_select and time_select. #2998 *csn, Bob Silva* - -* All date/datetime selects can now accept an array of month names with :use_month_names. Allows for localization. #363 *tomasj, Bob Silva* - -* Adds :time_separator to select_time and :date_separator to select_datetime. Preserves BC. #3811 *Bob Silva* - -* @response.redirect_url works with 201 Created responses: just return headers['Location'] rather than checking the response status. *Jeremy Kemper* - -* Fixed that HEAD should return the proper Content-Length header (that is, actually use @body.size, not just 0) *David Heinemeier Hansson* - -* Added GET-masquarading for HEAD, so request.method will return :get even for HEADs. This will help anyone relying on case request.method to automatically work with HEAD and map.resources will also allow HEADs to all GET actions. Rails automatically throws away the response content in a reply to HEAD, so you don't even need to worry about that. If you, for whatever reason, still need to distinguish between GET and HEAD in some edge case, you can use Request#head? and even Request.headers["REQUEST_METHOD"] for get the "real" answer. Closes #6694 *David Heinemeier Hansson* - - -## 1.13.0 RC1 (r5619, November 22nd, 2006) ## - -* Update Routing to complain when :controller is not specified by a route. Closes #6669. *Nicholas Seckar* - -* Ensure render_to_string cleans up after itself when an exception is raised. #6658 *rsanheim* - -* Update to Prototype and script.aculo.us [5579]. *Sam Stephenson, Thomas Fuchs* - -* simple_format helper doesn't choke on nil. #6644 *jerry426* - -* Reuse named route helper module between Routing reloads. Use remove_method to delete named route methods after each load. Since the module is never collected, this fixes a significant memory leak. *Nicholas Seckar* - -* Deprecate standalone components. *Jeremy Kemper* - -* Always clear model associations from session. #4795 *sd@notso.net, andylien@gmail.com* - -* Remove JavaScriptLiteral in favor of ActiveSupport::JSON::Variable. *Sam Stephenson* - -* Sync ActionController::StatusCodes::STATUS_CODES with http://www.iana.org/assignments/http-status-codes. #6586 *dkubb* - -* Multipart form values may have a content type without being treated as uploaded files if they do not provide a filename. #6401 *Andreas Schwarz, Jeremy Kemper* - -* assert_response supports symbolic status codes. #6569 *Kevin Clark* - assert_response :ok - assert_response :not_found - assert_response :forbidden - -* Cache parsed query parameters. #6559 *Stefan Kaes* - -* Deprecate JavaScriptHelper#update_element_function, which is superseeded by RJS *Thomas Fuchs* - -* Fix invalid test fixture exposed by stricter Ruby 1.8.5 multipart parsing. #6524 *Bob Silva* - -* Set ActionView::Base.default_form_builder once rather than passing the :builder option to every form or overriding the form helper methods. *Jeremy Kemper* - -* Deprecate expire_matched_fragments. Use expire_fragment instead. #6535 *Bob Silva* - -* Deprecate start_form_tag and end_form_tag. Use form_tag / '' from now on. *Rick Olson* - -* Added block-usage to PrototypeHelper#form_remote_tag, document block-usage of FormTagHelper#form_tag *Rick Olson* - -* Add a 0 margin/padding div around the hidden _method input tag that form_tag outputs. *Rick Olson* - -* Added block-usage to TagHelper#content_tag *DHH*. Example: - - <% content_tag :div, :class => "strong" %> - Hello world! - <% end %> - - Will output: -
Hello world!
- -* Deprecated UrlHelper#link_to_image and UrlHelper#link_to :post => true #6409 *Bob Silva* - -* Upgraded NumberHelper with number_to_phone support international formats to comply with ITU E.123 by supporting area codes with less than 3 digits, added precision argument to number_to_human_size (defaults to 1) #6421 *Bob Silva* - -* Fixed that setting RAILS_ASSET_ID to "" should not add a trailing slash after assets #6454 *Bob Silva/chrismear* - -* Force *_url named routes to show the host in ActionView *Rick Olson* - - <%= url_for ... %> # no host - <%= foo_path %> # no host - <%= foo_url %> # host! - -* Add support for converting blocks into function arguments to JavaScriptGenerator#call and JavaScriptProxy#call. *Sam Stephenson* - -* Add JavaScriptGenerator#literal for wrapping a string in an object whose #to_json is the string itself. *Sam Stephenson* - -* Add <%= escape_once html %> to escape html while leaving any currently escaped entities alone. Fix button_to double-escaping issue. *Rick Olson* - -* Fix double-escaped entities, such as &amp;, &#123;, etc. *Rick Olson* - -* Fix routing to correctly determine when generation fails. Closes #6300. *psross*. - -* Fix broken assert_generates when extra keys are being checked. *Jamis Buck* - -* Replace KCODE checks with String#chars for truncate. Closes #6385 *Manfred Stienstra* - -* Make page caching respect the format of the resource that is being requested even if the current route is the default route so that, e.g. posts.rss is not transformed by url_for to '/' and subsequently cached as '/index.html' when it should be cached as '/posts.rss'. *Marcel Molina Jr.* - -* Use String#chars in TextHelper::excerpt. Closes #6386 *Manfred Stienstra* - -* Fix relative URL root matching problems. *Mark Imbriaco* - -* Fix filter skipping in controller subclasses. #5949, #6297, #6299 *Martin Emde* - -* render_text may optionally append to the response body. render_javascript appends by default. This allows you to chain multiple render :update calls by setting @performed_render = false between them (awaiting a better public API). *Jeremy Kemper* - -* Rename test assertion to prevent shadowing. Closes #6306. *psross* - -* Fixed that NumberHelper#number_to_delimiter should respect precision of higher than two digits #6231 *Philip Hallstrom* - -* Fixed that FormHelper#radio_button didn't respect an :id being passed in #6266 *evansj* - -* Added an html_options hash parameter to javascript_tag() and update_page_tag() helpers #6311 *tzaharia*. Example: - - update_page_tag :defer => 'true' { |page| ... } - - Gives: - - - - Which is needed for dealing with the IE6 DOM when it's not yet fully loaded. - -* Fixed that rescue template path shouldn't be hardcoded, then it's easier to hook in your own #6295 *Mike Naberezny* - -* Fixed escaping of backslashes in JavaScriptHelper#escape_javascript #6302 *sven@c3d2.de* - -* Fixed that some 500 rescues would cause 500's themselves because the response had not yet been generated #6329 *cmselmer* - -* respond_to :html doesn't assume .rhtml. #6281 *Hampton Catlin* - -* Fixed some deprecation warnings in ActionPack *Rick Olson* - -* assert_select_rjs decodes escaped unicode chars since the Javascript generators encode them. #6240 *japgolly* - -* Deprecation: @cookies, @headers, @request, @response will be removed after 1.2. Use the corresponding method instead. *Jeremy Kemper* - -* Make the :status parameter expand to the default message for that status code if it is an integer. Also support symbol statuses. *Jamis Buck*. Examples: - - head :status => 404 # expands to "404 Not Found" - head :status => :not_found # expands to "404 Not Found" - head :status => :created # expands to "201 Created" - -* Add head(options = {}) for responses that have no body. *Jamis Buck*. Examples: - - head :status => 404 # return an empty response with a 404 status - head :location => person_path(@person), :status => 201 - -* Fix bug that kept any before_filter except the first one from being able to halt the before_filter chain. *Rick Olson* - -* strip_links is case-insensitive. #6285 *tagoh, Bob Silva* - -* Clear the cache of possible controllers whenever Routes are reloaded. *Nicholas Seckar* - -* Filters overhaul including meantime filter support using around filters + blocks. #5949 *Martin Emde, Roman Le Negrate, Stefan Kaes, Jeremy Kemper* - -* Update CGI process to allow sessions to contain namespaced models. Closes #4638. *dfelstead@site5.com* - -* Fix routing to respect user provided requirements and defaults when assigning default routing options (such as :action => 'index'). Closes #5950. *Nicholas Seckar* - -* Rescue Errno::ECONNRESET to handle an unexpectedly closed socket connection. Improves SCGI reliability. #3368, #6226 *sdsykes, fhanshaw@vesaria.com* - -* Added that respond_to blocks will automatically set the content type to be the same as is requested *DHH*. Examples: - - respond_to do |format| - format.html { render :text => "I'm being sent as text/html" } - format.rss { render :text => "I'm being sent as application/rss+xml" } - format.atom { render :text => "I'm being sent as application/xml", :content_type => Mime::XML } - end - -* Added utf-8 as the default charset for all renders. You can change this default using ActionController::Base.default_charset=(encoding) *David Heinemeier Hansson* - -* Added proper getters and setters for content type and charset *DHH*. Example of what we used to do: - - response.headers["Content-Type"] = "application/atom+xml; charset=utf-8" - - ...now: - - response.content_type = Mime::ATOM - response.charset = "utf-8" - -* Declare file extensions exempt from layouts. #6219 *brandon* - Example: ActionController::Base.exempt_from_layout 'rpdf' - -* Add chained replace/update support for assert_select_rjs *Rick Olson* - - Given RJS like... - - page['test1'].replace "
foo
" - page['test2'].replace_html "
foo
" - - Test it with... - - assert_select_rjs :chained_replace - assert_select_rjs :chained_replace, "test1" - - assert_select_rjs :chained_replace_html - assert_select_rjs :chained_replace_html, "test2" - -* Load helpers in alphabetical order for consistency. Resolve cyclic javascript_helper dependency. #6132, #6178 *choonkeat@gmail.com* - -* Skip params with empty names, such as the &=Save query string from . #2569 *Manfred Stienstra, raphinou@yahoo.com* - -* Fix assert_tag so that :content => "foo" does not match substrings, but only exact strings. Use :content => /foo/ to match substrings. #2799 *Eric Hodel* - -* Update JavaScriptGenerator#show/hide/toggle/remove to new Prototype syntax for multiple ids, #6068 *petermichaux@gmail.com* - -* Update UrlWriter to support :only_path. *Nicholas Seckar, Dave Thomas* - -* Fixed JavaScriptHelper#link_to_function and JavaScriptHelper#button_to_function to have the script argument be optional *DHH*. So what used to require a nil, like this: - - link_to("Hider", nil, :class => "hider_link") { |p| p[:something].hide } - - ...can be written like this: - - link_to("Hider", :class => "hider_link") { |p| p[:something].hide } - -* Added access to nested attributes in RJS #4548 *richcollins@gmail.com*. Examples: - - page['foo']['style'] # => $('foo').style; - page['foo']['style']['color'] # => $('blank_slate').style.color; - page['foo']['style']['color'] = 'red' # => $('blank_slate').style.color = 'red'; - page['foo']['style'].color = 'red' # => $('blank_slate').style.color = 'red'; - -* Fixed that AssetTagHelper#image_tag and others using compute_public_path should not modify the incoming source argument (closes #5102) *eule@space.ch* - -* Deprecated the auto-appending of .png to AssetTagHelper#image_tag calls that doesn't have an extension *David Heinemeier Hansson* - -* Fixed FormOptionsHelper#select to respect :selected value #5813 - -* Fixed TextHelper#simple_format to deal with multiple single returns within a single paragraph #5835 *moriq@moriq.com* - -* Fixed TextHelper#pluralize to handle 1 as a string #5909 *rails@bencurtis.com* - -* Improved resolution of DateHelper#distance_of_time_in_words for better precision #5994 *Bob Silva* - -* Changed that uncaught exceptions raised any where in the application will cause RAILS_ROOT/public/500.html to be read and shown instead of just the static "Application error (Rails)" *David Heinemeier Hansson* - -* Added deprecation language for pagination which will become a plugin by Rails 2.0 *David Heinemeier Hansson* - -* Added deprecation language for in_place_editor and auto_complete_field that both pieces will become plugins by Rails 2.0 *David Heinemeier Hansson* - -* Deprecated all of ActionController::Dependencies. All dependency loading is now handled from Active Support *David Heinemeier Hansson* - -* Added assert_select* for CSS selector-based testing (deprecates assert_tag) #5936 *assaf.arkin@gmail.com* - -* radio_button_tag generates unique id attributes. #3353 *Bob Silva, somekool@gmail.com* - -* strip_tags passes through blank args such as nil or "". #2229, #6702 *duncan@whomwah.com, dharana* - -* Cleanup assert_tag :children counting. #2181 *jamie@bravenet.com* - -* button_to accepts :method so you can PUT and DELETE with it. #6005 *Dan Webb* - -* Update sanitize text helper to strip plaintext tags, and . *Rick Olson* - -* Add routing tests to assert that RoutingError is raised when conditions aren't met. Closes #6016 *Nathan Witmer* - -* Make auto_link parse a greater subset of valid url formats. *Jamis Buck* - -* Integration tests: headers beginning with X aren't excluded from the HTTP_ prefix, so X-Requested-With becomes HTTP_X_REQUESTED_WITH as expected. *Mike Clark* - -* Switch to using FormEncodedPairParser for parsing request parameters. *Nicholas Seckar, David Heinemeier Hansson* - -* respond_to .html now always renders #{action_name}.rhtml so that registered custom template handlers do not override it in priority. Custom mime types require a block and throw proper error now. *Tobias Lütke* - -* Deprecation: test deprecated instance vars in partials. *Jeremy Kemper* - -* Add UrlWriter to allow writing urls from Mailers and scripts. *Nicholas Seckar* - -* Relax Routing's anchor pattern warning; it was preventing use of [^/] inside restrictions. *Nicholas Seckar* - -* Add controller_paths variable to Routing. *Nicholas Seckar* - -* Fix assert_redirected_to issue with named routes for module controllers. *Rick Olson* - -* Tweak RoutingError message to show option diffs, not just missing named route significant keys. *Rick Olson* - -* Invoke method_missing directly on hidden actions. Closes #3030. *Nicholas Seckar* - -* Add RoutingError exception when RouteSet fails to generate a path from a Named Route. *Rick Olson* - -* Replace Reloadable with Reloadable::Deprecated. *Nicholas Seckar* - -* Deprecation: check whether instance variables have been monkeyed with before assigning them to deprecation proxies. Raises a RuntimeError if so. *Jeremy Kemper* - -* Add support for the param_name parameter to the auto_complete_field helper. #5026 *david.a.williams@gmail.com* - -* Deprecation! @params, @session, @flash will be removed after 1.2. Use the corresponding instance methods instead. You'll get printed warnings during tests and logged warnings in dev mode when you access either instance variable directly. *Jeremy Kemper* - -* Make Routing noisy when an anchor regexp is assigned to a segment. #5674 *François Beausoleil* - -* Added months and years to the resolution of DateHelper#distance_of_time_in_words, such that "60 days ago" becomes "2 months ago" #5611 *pjhyett@gmail.com* - -* Make controller_path available as an instance method. #5724 *jmckible@gmail.com* - -* Update query parser to support adjacent hashes. *Nicholas Seckar* - -* Make action caching aware of different formats for the same action so that, e.g. foo.xml is cached separately from foo.html. Implicitly set content type when reading in cached content with mime revealing extensions so the entire onous isn't on the webserver. *Marcel Molina Jr.* - -* Restrict Request Method hacking with ?_method to POST requests. *Rick Olson* - -* Fixed the new_#{resource}_url route and added named route tests for Simply Restful. *Rick Olson* - -* Added map.resources from the Simply Restful plugin *DHH*. Examples (the API has changed to use plurals!): - - map.resources :messages - map.resources :messages, :comments - map.resources :messages, :new => { :preview => :post } - -* Fixed that integration simulation of XHRs should set Accept header as well *Edward Frederick* - -* TestRequest#reset_session should restore a TestSession, not a hash *Michael Koziarski* - -* Don't search a load-path of '.' for controller files *Jamis Buck* - -* Update integration.rb to require test_process explicitly instead of via Dependencies. *Nicholas Seckar* - -* Fixed that you can still access the flash after the flash has been reset in reset_session. Closes #5584 *lmarlow* - -* Allow form_for and fields_for to work with indexed form inputs. *Jeremy Kemper, Matt Lyon* - - <% form_for 'post[]', @post do |f| -%> - <% end -%> - -* Remove leak in development mode by replacing define_method with module_eval. *Nicholas Seckar* - -* Provide support for decimal columns to form helpers. Closes #5672. *Dave Thomas* - -* Pass :id => nil or :class => nil to error_messages_for to supress that html attribute. #3586 *olivier_ansaldi@yahoo.com* - -* Reset @html_document between requests so assert_tag works. #4810 *Jarkko Laine, easleydp@gmail.com* - -* Integration tests behave well with render_component. #4632 *edward.frederick@revolution.com, dev.rubyonrails@maxdunn.com* - -* Added exception handling of missing layouts #5373 *chris@ozmm.org* - -* Fixed that real files and symlinks should be treated the same when compiling templates #5438 *zachary@panandscan.com* - -* Fixed that the flash should be reset when reset_session is called #5584 *Shugo Maeda* - -* Added special case for "1 Byte" in NumberHelper#number_to_human_size #5593 *murpyh@rubychan.de* - -* Fixed proper form-encoded parameter parsing for requests with "Content-Type: application/x-www-form-urlencoded; charset=utf-8" (note the presence of a charset directive) *David Heinemeier Hansson* - -* Add route_name_path method to generate only the path for a named routes. For example, map.person will add person_path. *Nicholas Seckar* - -* Avoid naming collision among compiled view methods. *Jeremy Kemper* - -* Fix CGI extensions when they expect string but get nil in Windows. Closes #5276 *Mislav Marohnić* - -* Determine the correct template_root for deeply nested components. #2841 *s.brink@web.de* - -* Fix that routes with *path segments in the recall can generate URLs. *Rick Olson* - -* Fix strip_links so that it doesn't hang on multiline tags *Jamis Buck* - -* Remove problematic control chars in rescue template. #5316 *Stefan Kaes* - -* Make sure passed routing options are not mutated by routing code. #5314 *Blair Zajac* - -* Make sure changing the controller from foo/bar to bing/bang does not change relative to foo. *Jamis Buck* - -* Escape the path before routing recognition. #3671 - -* Make sure :id and friends are unescaped properly. #5275 *me@julik.nl* - -* Rewind readable CGI params so others may reread them (such as CGI::Session when passing the session id in a multipart form). #210 *mklame@atxeu.com, matthew@walker.wattle.id.au* - -* Added Mime::TEXT (text/plain) and Mime::ICS (text/calendar) as new default types *David Heinemeier Hansson* - -* Added Mime::Type.register(string, symbol, synonyms = []) for adding new custom mime types *DHH*. Example: Mime::Type.register("image/gif", :gif) - -* Added support for Mime objects in render :content_type option *DHH*. Example: render :text => some_atom, :content_type => Mime::ATOM - -* Add :status option to send_data and send_file. Defaults to '200 OK'. #5243 *Manfred Stienstra * - -* Routing rewrite. Simpler, faster, easier to understand. The published API for config/routes.rb is unchanged, but nearly everything else is different, so expect breakage in plugins and libs that try to fiddle with routes. *Nicholas Seckar, Jamis Buck* - - map.connect '/foo/:id', :controller => '...', :action => '...' - map.connect '/foo/:id.:format', :controller => '...', :action => '...' - map.connect '/foo/:id', ..., :conditions => { :method => :get } - -* Cope with missing content type and length headers. Parse parameters from multipart and urlencoded request bodies only. *Jeremy Kemper* - -* Accept multipart PUT parameters. #5235 *guy.naor@famundo.com* - -* Added interrogation of params[:format] to determine Accept type. If :format is specified and matches a declared extension, like "rss" or "xml", that mime type will be put in front of the accept handler. This means you can link to the same action from different extensions and use that fact to determine output *DHH*. Example: - - class WeblogController < ActionController::Base - def index - @posts = Post.find :all - - respond_to do |format| - format.html - format.xml { render :xml => @posts.to_xml } - format.rss { render :action => "feed.rxml" } - end - end - end - - \# returns HTML when requested by a browser, since the browser - \# has the HTML mimetype at the top of its priority list - Accept: text/html - GET /weblog - - \# returns the XML - Accept: application/xml - GET /weblog - - \# returns the HTML - Accept: application/xml - GET /weblog.html - - \# returns the XML - Accept: text/html - GET /weblog.xml - - All this relies on the fact that you have a route that includes .:format. - -* Expanded :method option in FormTagHelper#form_tag, FormHelper#form_for, PrototypeHelper#remote_form_for, PrototypeHelper#remote_form_tag, and PrototypeHelper#link_to_remote to allow for verbs other than GET and POST by automatically creating a hidden form field named _method, which will simulate the other verbs over post *David Heinemeier Hansson* - -* Added :method option to UrlHelper#link_to, which allows for using other verbs than GET for the link. This replaces the :post option, which is now deprecated. Example: link_to "Destroy", person_url(http://github.com/:id%20=%3E%20person), :method => :delete *David Heinemeier Hansson* - -* follow_redirect doesn't complain about being redirected to the same controller. #5153 *dymo@mk.ukrtelecom.ua* - -* Add layout attribute to response object with the name of the layout that was rendered, or nil if none rendered. *Kevin Clark* - -* Fix NoMethodError when parsing params like &&. *Adam Greenfield* - -* form.text_area handles the :size option just like the original text_area (:size => '60x10' becomes cols="60" rows="10"). *Jeremy Kemper* - -* Excise ingrown code from FormOptionsHelper#options_for_select. #5008 *anonymous* - -* Small fix in routing to allow dynamic routes (broken after [4242]) *Rick Olson* - - map.connect '*path', :controller => 'files', :action => 'show' - -* Use #flush between switching from #write to #syswrite. Closes #4907. *Blair Zajac * - -* Allow error_messages_for to report errors for multiple objects, as well as support for customizing the name of the object in the error summary header. Closes #4186. *andrew@redlinesoftware.com, Marcel Molina Jr.* - - error_messages_for :account, :user, :subscription, :object_name => :account - -* Fix assert_redirected_to tests according to real-world usage. Also, don't fail if you add an extra :controller option: *Rick Olson* - - redirect_to :action => 'new' - assert_redirected_to :controller => 'monkeys', :action => 'new' - -* Diff compared routing options. Allow #assert_recognizes to take a second arg as a hash to specify optional request method *Rick Olson* - - assert_recognizes({:controller => 'users', :action => 'index'}, 'users') - assert_recognizes({:controller => 'users', :action => 'create'}, {:path => 'users', :method => :post}) - -* Diff compared options with #assert_redirected_to *Rick Olson* - -* Add support in routes for semicolon delimited "subpaths", like /books/:id;:action *Jamis Buck* - -* Change link_to_function and button_to_function to (optionally) take an update_page block instead of a JavaScript string. Closes #4804. *zraii@comcast.net, Sam Stephenson* - -* Modify routing so that you can say :require => { :method => :post } for a route, and the route will never be selected unless the request method is POST. Only works for route recognition, not for route generation. *Jamis Buck* - -* Added :add_headers option to verify which merges a hash of name/value pairs into the response's headers hash if the prerequisites cannot be satisfied. *Sam Stephenson* - ex. verify :only => :speak, :method => :post, - :render => { :status => 405, :text => "Must be post" }, - :add_headers => { "Allow" => "POST" } - - -## 1.12.5 (August 10th, 2006) ## - -* Updated security fix - - -## 1.12.4 (August 8th, 2006) ## - -* Cache CgiRequest#request_parameters so that multiple calls don't re-parse multipart data. *Rick Olson* - -* Fixed that remote_form_for can leave out the object parameter and default to the instance variable of the object_name, just like form_for *David Heinemeier Hansson* - -* Added ActionController.filter_parameter_logging that makes it easy to remove passwords, credit card numbers, and other sensitive information from being logged when a request is handled. #1897 *jeremye@bsa.ca.gov* - -* Fixed that real files and symlinks should be treated the same when compiling templates. #5438 *zachary@panandscan.com* - -* Add :status option to send_data and send_file. Defaults to '200 OK'. #5243 *Manfred Stienstra * - -* Update documentation for erb trim syntax. #5651 *matt@mattmargolis.net* - -* Short documentation to mention use of Mime::Type.register. #5710 *choonkeat@gmail.com* - - -## 1.12.3 (June 28th, 2006) ## - -* Fix broken traverse_to_controller. We now: - Look for a _controller.rb file under RAILS_ROOT to load. - If we find it, we require_dependency it and return the controller it defined. (If none was defined we stop looking.) - If we don't find it, we look for a .rb file under RAILS_ROOT to load. If we find it, and it loads a constant we keep looking. - Otherwise we check to see if a directory of the same name exists, and if it does we create a module for it. - - -## 1.12.2 (June 27th, 2006) ## - -* Refinement to avoid exceptions in traverse_to_controller. - -* (Hackish) Fix loading of arbitrary files in Ruby's load path by traverse_to_controller. *Nicholas Seckar* - - -## 1.12.1 (April 6th, 2006) ## - -* Fixed that template extensions would be cached development mode #4624 *Stefan Kaes* - -* Update to Prototype 1.5.0_rc0 *Sam Stephenson* - -* Honor skipping filters conditionally for only certain actions even when the parent class sets that filter to conditionally be executed only for the same actions. #4522 *Marcel Molina Jr.* - -* Delegate xml_http_request in integration tests to the session instance. *Jamis Buck* - -* Update the diagnostics template skip the useless '' text. *Nicholas Seckar* - -* CHANGED DEFAULT: Don't parse YAML input by default, but keep it available as an easy option *David Heinemeier Hansson* - -* Add additional autocompleter options *aballai, Thomas Fuchs* - -* Fixed fragment caching of binary data on Windows #4493 *bellis@deepthought.org* - -* Applied Prototype $() performance patches (#4465, #4477) and updated script.aculo.us *Sam Stephenson, Thomas Fuchs* - -* Added automated timestamping to AssetTagHelper methods for stylesheets, javascripts, and images when Action Controller is run under Rails *DHH*. Example: - - image_tag("rails.png") # => 'Rails' - - ...to avoid frequent stats (not a problem for most people), you can set RAILS_ASSET_ID in the ENV to avoid stats: - - ENV["RAILS_ASSET_ID"] = "2345" - image_tag("rails.png") # => 'Rails' - - This can be used by deployment managers to set the asset id by application revision - - -## 1.12.0 (March 27th, 2006) ## - -* Add documentation for respond_to. *Jamis Buck* - -* Fixed require of bluecloth and redcloth when gems haven't been loaded #4446 *murphy@cYcnus.de* - -* Update to Prototype 1.5.0_pre1 *Sam Stephenson* - -* Change #form_for and #fields_for so that the second argument is not required *Dave Thomas* - - <% form_for :post, @post, :url => { :action => 'create' } do |f| -%> - - becomes... - - <% form_for :post, :url => { :action => 'create' } do |f| -%> - -* Update to script.aculo.us 1.6 *Thomas Fuchs* - -* Enable application/x-yaml processing by default *Jamis Buck* - -* Fix double url escaping of remote_function. Add :escape => false option to ActionView's url_for. *Nicholas Seckar* - -* Add :script option to in_place_editor to support evalScripts (closes #4194) *Cody Fauser* - -* Fix mixed case enumerable methods in the JavaScript Collection Proxy (closes #4314) *Cody Fauser* - -* Undo accidental escaping for mail_to; add regression test. *Nicholas Seckar* - -* Added nicer message for assert_redirected_to (closes #4294) *court3nay* - - assert_redirected_to :action => 'other_host', :only_path => false - - when it was expecting... - - redirected_to :action => 'other_host', :only_path => true, :host => 'other.test.host' - - gives the error message... - - response is not a redirection to all of the options supplied (redirection is <{:only_path=>false, :host=>"other.test.host", :action=>"other_host"}>), difference: <{:only_path=>"true", :host=>"other.test.host"}> - -* Change url_for to escape the resulting URLs when called from a view. *Nicholas Seckar, coffee2code* - -* Added easy support for testing file uploads with fixture_file_upload #4105 *turnip@turnipspatch.com*. Example: - - # Looks in Test::Unit::TestCase.fixture_path + '/files/spongebob.png' - post :change_avatar, :avatar => fixture_file_upload('/files/spongebob.png', 'image/png') - -* Fixed UrlHelper#current_page? to behave even when url-escaped entities are present #3929 *jeremy@planetargon.com* - -* Add ability for relative_url_root to be specified via an environment variable RAILS_RELATIVE_URL_ROOT. *isaac@reuben.com, Nicholas Seckar* - -* Fixed link_to "somewhere", :post => true to produce valid XHTML by using the parentnode instead of document.body for the instant form #3007 *Bob Silva* - -* Added :function option to PrototypeHelper#observe_field/observe_form that allows you to call a function instead of submitting an ajax call as the trigger #4268 *jonathan@daikini.com* - -* Make Mime::Type.parse consider q values (if any) *Jamis Buck* - -* XML-formatted requests are typecast according to "type" attributes for :xml_simple *Jamis Buck* - -* Added protection against proxy setups treating requests as local even when they're not #3898 *Steve Purcell* - -* Added TestRequest#raw_post that simulate raw_post from CgiRequest #3042 *François Beausoleil* - -* Underscore dasherized keys in formatted requests *Jamis Buck* - -* Add MimeResponds::Responder#any for managing multiple types with identical responses *Jamis Buck* - -* Make the xml_http_request testing method set the HTTP_ACCEPT header *Jamis Buck* - -* Add Verification to scaffolds. Prevent destructive actions using GET *Michael Koziarski* - -* Avoid hitting the filesystem when using layouts by using a File.directory? cache. *Stefan Kaes, Nicholas Seckar* - -* Simplify ActionController::Base#controller_path *Nicholas Seckar* - -* Added simple alert() notifications for RJS exceptions when config.action_view.debug_rjs = true. *Sam Stephenson* - -* Added :content_type option to render, so you can change the content type on the fly *DHH*. Example: render :action => "atom.rxml", :content_type => "application/atom+xml" - -* CHANGED DEFAULT: The default content type for .rxml is now application/xml instead of type/xml, see http://www.xml.com/pub/a/2004/07/21/dive.html for reason *David Heinemeier Hansson* - -* Added option to render action/template/file of a specific extension (and here by template type). This means you can have multiple templates with the same name but a different extension *DHH*. Example: - - class WeblogController < ActionController::Base - def index - @posts = Post.find :all - - respond_to do |type| - type.html # using defaults, which will render weblog/index.rhtml - type.xml { render :action => "index.rxml" } - type.js { render :action => "index.rjs" } - end - end - end - -* Added better support for using the same actions to output for different sources depending on the Accept header *DHH*. Example: - - class WeblogController < ActionController::Base - def create - @post = Post.create(params[:post]) - - respond_to do |type| - type.js { render } # renders create.rjs - type.html { redirect_to :action => "index" } - type.xml do - headers["Location"] = url_for(:action => "show", :id => @post) - render(:nothing, :status => "201 Created") - end - end - end - end - -* Added Base#render(:xml => xml) that works just like Base#render(:text => text), but sets the content-type to text/xml and the charset to UTF-8 *David Heinemeier Hansson* - -* Integration test's url_for now runs in the context of the last request (if any) so after post /products/show/1 url_for :action => 'new' will yield /product/new *Tobias Lütke* - -* Re-added mixed-in helper methods for the JavascriptGenerator. Moved JavascriptGenerators methods to a module that is mixed in after the helpers are added. Also fixed that variables set in the enumeration methods like #collect are set correctly. Documentation added for the enumeration methods *Rick Olson*. Examples: - - page.select('#items li').collect('items') do |element| - element.hide - end - # => var items = $$('#items li').collect(function(value, index) { return value.hide(); }); - -* Added plugin support for parameter parsers, which allows for better support for REST web services. By default, posts submitted with the application/xml content type is handled by creating a XmlSimple hash with the same name as the root element of the submitted xml. More handlers can easily be registered like this: - - # Assign a new param parser to a new content type - ActionController::Base.param_parsers['application/atom+xml'] = Proc.new do |data| - node = REXML::Document.new(post) - { node.root.name => node.root } - end - - # Assign the default XmlSimple to a new content type - ActionController::Base.param_parsers['application/backpack+xml'] = :xml_simple - - Default YAML web services were retired, ActionController::Base.param_parsers carries an example which shows how to get this functionality back. As part of this new plugin support, request.[formatted_post?, xml_post?, yaml_post? and post_format] were all deprecated in favor of request.content_type *Tobias Lütke* -* Fixed Effect.Appear in effects.js to work with floats in Safari #3524, #3813, #3044 *Thomas Fuchs* - -* Fixed that default image extension was not appended when using a full URL with AssetTagHelper#image_tag #4032, #3728 *rubyonrails@beautifulpixel.com* - -* Added that page caching will only happen if the response code is less than 400 #4033 *g.bucher@teti.ch* - -* Add ActionController::IntegrationTest to allow high-level testing of the way the controllers and routes all work together *Jamis Buck* - -* Added support to AssetTagHelper#javascript_include_tag for having :defaults appear anywhere in the list, so you can now make one call ala javascript_include_tag(:defaults, "my_scripts") or javascript_include_tag("my_scripts", :defaults) depending on how you want the load order #3506 *Bob Silva* - -* Added support for visual effects scoped queues to the visual_effect helper #3530 *Abdur-Rahman Advany* - -* Added .rxml (and any non-rhtml template, really) supportfor CaptureHelper#content_for and CaptureHelper#capture #3287 *Brian Takita* - -* Added script.aculo.us drag and drop helpers to RJS *Thomas Fuchs*. Examples: - - page.draggable 'product-1' - page.drop_receiving 'wastebasket', :url => { :action => 'delete' } - page.sortable 'todolist', :url => { action => 'change_order' } - -* Fixed that form elements would strip the trailing [] from the first parameter #3545 *ruby@bobsilva.com* - -* During controller resolution, update the NameError suppression to check for the expected constant. *Nicholas Seckar* - -* Update script.aculo.us to V1.5.3 *Thomas Fuchs* - -* Added various InPlaceEditor options, #3746, #3891, #3896, #3906 *Bill Burcham, ruairi, sl33p3r* - -* Added :count option to pagination that'll make it possible for the ActiveRecord::Base.count call to using something else than * for the count. Especially important for count queries using DISTINCT #3839 *Stefan Kaes* - -* Update script.aculo.us to V1.5.2 *Thomas Fuchs* - -* Added element and collection proxies to RJS *DHH*. Examples: - - page['blank_slate'] # => $('blank_slate'); - page['blank_slate'].show # => $('blank_slate').show(); - page['blank_slate'].show('first').up # => $('blank_slate').show('first').up(); - - page.select('p') # => $$('p'); - page.select('p.welcome b').first # => $$('p.welcome b').first(); - page.select('p.welcome b').first.hide # => $$('p.welcome b').first().hide(); - -* Add JavaScriptGenerator#replace for replacing an element's "outer HTML". #3246 *tom@craz8.com, Sam Stephenson* - -* Remove over-engineered form_for code for a leaner implementation. *Nicholas Seckar* - -* Document form_for's :html option. *Nicholas Seckar* - -* Major components cleanup and speedup. #3527 *Stefan Kaes* - -* Fix problems with pagination and :include. *Kevin Clark* - -* Add ActiveRecordTestCase for testing AR integration. *Kevin Clark* - -* Add Unit Tests for pagination *Kevin Clark* - -* Add :html option for specifying form tag options in form_for. *Sam Stephenson* - -* Replace dubious controller parent class in filter docs. #3655, #3722 *info@rhalff.com, eigentone@gmail.com* - -* Don't interpret the :value option on text_area as an html attribute. Set the text_area's value. #3752 *gabriel@gironda.org* - -* Fix remote_form_for creates a non-ajax form. *Rick Olson* - -* Don't let arbitrary classes match as controllers -- a potentially dangerous bug. *Nicholas Seckar* - -* Fix Routing tests. Fix routing where failing to match a controller would prevent the rest of routes from being attempted. *Nicholas Seckar* - -* Add :builder => option to form_for and friends. *Nicholas Seckar, Rick Olson* - -* Fix controller resolution to avoid accidentally inheriting a controller from a parent module. *Nicholas Seckar* - -* Set sweeper's @controller to nil after a request so that the controller may be collected between requests. *Nicholas Seckar* - -* Subclasses of ActionController::Caching::Sweeper should be Reloadable. *Rick Olson* - -* Document the :xhr option for verifications. #3666 *leeo* - -* Added :only and :except controls to skip_before/after_filter just like for when you add filters *David Heinemeier Hansson* - -* Ensure that the instance variables are copied to the template when performing render :update. *Nicholas Seckar* - -* Add the ability to call JavaScriptGenerator methods from helpers called in update blocks. *Sam Stephenson* Example: - module ApplicationHelper - def update_time - page.replace_html 'time', Time.now.to_s(:db) - page.visual_effect :highlight, 'time' - end - end - - class UserController < ApplicationController - def poll - render :update { |page| page.update_time } - end - end - -* Add render(:update) to ActionView::Base. *Sam Stephenson* - -* Fix render(:update) to not render layouts. *Sam Stephenson* - -* Fixed that SSL would not correctly be detected when running lighttpd/fcgi behind lighttpd w/mod_proxy #3548 *Steve Purcell* - -* Added the possibility to specify atomatic expiration for the memcachd session container #3571 *Stefan Kaes* - -* Change layout discovery to take into account the change in semantics with File.join and nil arguments. *Marcel Molina Jr.* - -* Raise a RedirectBackError if redirect_to :back is called when there's no HTTP_REFERER defined #3049 *Kevin Clark* - -* Treat timestamps like datetimes for scaffolding purposes #3388 *Maik Schmidt* - -* Fix IE bug with link_to "something", :post => true #3443 *Justin Palmer* - -* Extract Test::Unit::TestCase test process behavior into an ActionController::TestProcess module. *Sam Stephenson* - -* Pass along blocks from render_to_string to render. *Sam Stephenson* - -* Add render :update for inline RJS. *Sam Stephenson* Example: - class UserController < ApplicationController - def refresh - render :update do |page| - page.replace_html 'user_list', :partial => 'user', :collection => @users - page.visual_effect :highlight, 'user_list' - end - end - end - -* allow nil objects for error_messages_for *Michael Koziarski* - -* Refactor human_size to exclude decimal place if it is zero. *Marcel Molina Jr.* - -* Update to Prototype 1.5.0_pre0 *Sam Stephenson* - -* Automatically discover layouts when a controller is namespaced. #2199, #3424 *me@jonnii.com rails@jeffcole.net Marcel Molina Jr.* - -* Add support for multiple proxy servers to CgiRequest#host *gaetanot@comcast.net* - -* Documentation typo fix. #2367 *Blair Zajac* - -* Remove Upload Progress. #2871 *Sean Treadway* - -* Fix typo in function name mapping in auto_complete_field. #2929 #3446 *doppler@gmail.com phil.ross@gmail.com* - -* Allow auto-discovery of third party template library layouts. *Marcel Molina Jr.* - -* Have the form builder output radio button, not check box, when calling the radio button helper. #3331 *LouisStAmour@gmail.com* - -* Added assignment of the Autocompleter object created by JavaScriptMacroHelper#auto_complete_field to a local javascript variables *David Heinemeier Hansson* - -* Added :on option for PrototypeHelper#observe_field that allows you to specify a different callback hook to have the observer trigger on *David Heinemeier Hansson* - -* Added JavaScriptHelper#button_to_function that works just like JavaScriptHelper#link_to_function but uses a button instead of a href *David Heinemeier Hansson* - -* Added that JavaScriptHelper#link_to_function will honor existing :onclick definitions when adding the function call *David Heinemeier Hansson* - -* Added :disable_with option to FormTagHelper#submit_tag to allow for easily disabled submit buttons with different text *David Heinemeier Hansson* - -* Make auto_link handle nil by returning quickly if blank? *Scott Barron* - -* Make auto_link match urls with a port number specified. *Marcel Molina Jr.* - -* Added support for toggling visual effects to ScriptaculousHelper::visual_effect, #3323. *Thomas Fuchs* - -* Update to script.aculo.us to 1.5.0 rev. 3343 *Thomas Fuchs* - -* Added :select option for JavaScriptMacroHelper#auto_complete_field that makes it easier to only use part of the auto-complete suggestion as the value for insertion *Thomas Fuchs* - -* Added delayed execution of Javascript from within RJS #3264 *devslashnull@gmail.com*. Example: - - page.delay(20) do - page.visual_effect :fade, 'notice' - end - -* Add session ID to default logging, but remove the verbose description of every step *David Heinemeier Hansson* - -* Add the following RJS methods: *Sam Stephenson* - - * alert - Displays an alert() dialog - * redirect_to - Changes window.location.href to simulate a browser redirect - * call - Calls a JavaScript function - * assign - Assigns to a JavaScript variable - * << - Inserts an arbitrary JavaScript string - -* Fix incorrect documentation for form_for *Nicholas Seckar* - -* Don't include a layout when rendering an rjs template using render's :template option. *Marcel Molina Jr.* - -## 1.1.2 (December 13th, 2005) ## - -* Become part of Rails 1.0 - -* Update to script.aculo.us 1.5.0 final (equals 1.5.0_rc6) *Thomas Fuchs* - -* Update to Prototype 1.4.0 final *Sam Stephenson* - -* Added form_remote_for (form_for meets form_remote_tag) *David Heinemeier Hansson* - -* Update to script.aculo.us 1.5.0_rc6 - -* More robust relative url root discovery for SCGI compatibility. This solves the 'SCGI routes problem' -- you no longer need to prefix all your routes with the name of the SCGI mountpoint. #3070 *Dave Ringoen* - -* Fix docs for text_area_tag. #3083. *Christopher Cotton* - -* Change form_for and fields_for method signatures to take object name and object as separate arguments rather than as a Hash. *David Heinemeier Hansson* - -* Introduce :selected option to the select helper. Allows you to specify a selection other than the current value of object.method. Specify :selected => nil to leave all options unselected. #2991 *Jonathan Viney * - -* Initialize @optional in routing code to avoid warnings about uninitialized access to an instance variable. *Nicholas Seckar* - -* Make ActionController's render honor the :locals option when rendering a :file. #1665. *Emanuel Borsboom, Marcel Molina Jr.* - -* Allow assert_tag(:conditions) to match the empty string when a tag has no children. Closes #2959. *Jamis Buck* - -* Update html-scanner to handle CDATA sections better. Closes #2970. *Jamis Buck* - -* Don't put flash in session if sessions are disabled. *Jeremy Kemper* - -* Strip out trailing &_= for raw post bodies. Closes #2868. *Sam Stephenson* - -* Pass multiple arguments to Element.show and Element.hide in JavaScriptGenerator instead of using iterators. *Sam Stephenson* - -* Improve expire_fragment documentation. #2966 *court3nay* - -* Correct docs for automatic layout assignment. #2610. *Charles M. Gerungan* - -* Always create new AR sessions rather than trying too hard to avoid database traffic. #2731 *Jeremy Kemper* - -* Update to Prototype 1.4.0_rc4. Closes #2943 (old Array.prototype.reverse behavior can be obtained by passing false as an argument). *Sam Stephenson* - -* Use Element.update('id', 'html') instead of $('id').innerHTML = 'html' in JavaScriptGenerator#replace_html so that script tags are evaluated. *Sam Stephenson* - -* Make rjs templates always implicitly skip out on layouts. *Marcel Molina Jr.* - -* Correct length for the truncate text helper. #2913 *Stefan Kaes* - -* Update to Prototype 1.4.0_rc3. Closes #1893, #2505, #2550, #2748, #2783. *Sam Stephenson* - -* Add support for new rjs templates which wrap an update_page block. *Marcel Molina Jr.* - -* Rename Version constant to VERSION. #2802 *Marcel Molina Jr.* - -* Correct time_zone_options_for_select docs. #2892 *pudeyo@rpi.com* - -* Remove the unused, slow response_dump and session_dump variables from error pages. #1222 *lmarlow* - -* Performance tweaks: use Set instead of Array to speed up prototype helper include? calls. Avoid logging code if logger is nil. Inline commonly-called template presence checks. #2880, #2881, #2882, #2883 *Stefan Kaes* - -* MemCache store may be given multiple addresses. #2869 *Ryan Carver * - -* Handle cookie parsing irregularity for certain Nokia phones. #2530 *zaitzow@gmail.com* - -* Added PrototypeHelper::JavaScriptGenerator and PrototypeHelper#update_page for easily modifying multiple elements in an Ajax response. *Sam Stephenson* Example: - - update_page do |page| - page.insert_html :bottom, 'list', '
  • Last item
  • ' - page.visual_effect :highlight, 'list' - page.hide 'status-indicator', 'cancel-link' - end - - generates the following JavaScript: - - new Insertion.Bottom("list", "
  • Last item
  • "); - new Effect.Highlight("list"); - ["status-indicator", "cancel-link"].each(Element.hide); - -* Refactored JavaScriptHelper into PrototypeHelper and ScriptaculousHelper *Sam Stephenson* - -* Update to latest script.aculo.us version (as of [3031]) - -* Updated docs for in_place_editor, fixes a couple bugs and offers extended support for external controls *Justin Palmer* - -* Update documentation for render :file. #2858 *Tom Werner* - -* Only include builtin filters whose filenames match /^[a-z][a-z_]*_helper.rb$/ to avoid including operating system metadata such as ._foo_helper.rb. #2855 *court3nay* - -* Added FormHelper#form_for and FormHelper#fields_for that makes it easier to work with forms for single objects also if they don't reside in instance variables *DHH*. Examples: - - <% form_for :person, @person, :url => { :action => "update" } do |f| %> - First name: <%= f.text_field :first_name %> - Last name : <%= f.text_field :last_name %> - Biography : <%= f.text_area :biography %> - Admin? : <%= f.check_box :admin %> - <% end %> - - <% form_for :person, person, :url => { :action => "update" } do |person_form| %> - First name: <%= person_form.text_field :first_name %> - Last name : <%= person_form.text_field :last_name %> - - <% fields_for :permission => person.permission do |permission_fields| %> - Admin? : <%= permission_fields.check_box :admin %> - <% end %> - <% end %> - -* options_for_select allows any objects which respond_to? :first and :last rather than restricting to Array and Range. #2824 *Jacob Robbins , Jeremy Kemper* - -* The auto_link text helper accepts an optional block to format the link text for each url and email address. Example: auto_link(post.body) { |text| truncate(text, 10) } *Jeremy Kemper* - -* assert_tag uses exact matches for string conditions, instead of partial matches. Use regex to do partial matches. #2799 *Jamis Buck* - -* CGI::Session::ActiveRecordStore.data_column_name = 'foobar' to use a different session data column than the 'data' default. *nbpwie102@sneakemail.com* - -* Do not raise an exception when default helper is missing; log a debug message instead. It's nice to delete empty helpers. *Jeremy Kemper* - -* Controllers with acronyms in their names (e.g. PDFController) require the correct default helper (PDFHelper in file pdf_helper.rb). #2262 *jeff@opendbms.com* - - -## 1.11.0 (November 7th, 2005) ## - -* Added request as instance method to views, so you can do <%= request.env["HTTP_REFERER"] %>, just like you can already access response, session, and the likes *David Heinemeier Hansson* - -* Fix conflict with assert_tag and Glue gem #2255 *david.felstead@gmail.com* - -* Add documentation to assert_tag indicating that it only works with well-formed XHTML #1937, #2570 *Jamis Buck* - -* Added action_pack.rb stub so that ActionPack::Version loads properly *Sam Stephenson* - -* Added short-hand to assert_tag so assert_tag :tag => "span" can be written as assert_tag "span" *David Heinemeier Hansson* - -* Added skip_before_filter/skip_after_filter for easier control of the filter chain in inheritance hierachies *DHH*. Example: - - class ApplicationController < ActionController::Base - before_filter :authenticate - end - - class WeblogController < ApplicationController - # will run the :authenticate filter - end - - class SignupController < ActionController::Base - # will not run the :authenticate filter - skip_before_filter :authenticate - end - -* Added redirect_to :back as a short-hand for redirect_to(request.env["HTTP_REFERER"]) *David Heinemeier Hansson* - -* Change javascript_include_tag :defaults to not use script.aculo.us loader, which facilitates the use of plugins for future script.aculo.us and third party javascript extensions, and provide register_javascript_include_default for plugins to specify additional JavaScript files to load. Removed slider.js and builder.js from actionpack. *Thomas Fuchs* - -* Fix problem where redirecting components can cause an infinite loop *Rick Olson* - -* Added support for the queue option on visual_effect *Thomas Fuchs* - -* Update script.aculo.us to V1.5_rc4 *Thomas Fuchs* - -* Fix that render :text didn't interpolate instance variables #2629, #2626 *Stefan Kaes* - -* Fix line number detection and escape RAILS_ROOT in backtrace Regexp *Nicholas Seckar* - -* Fixed document.getElementsByClassName from Prototype to be speedy again *Sam Stephenson* - -* Recognize ./#{RAILS_ROOT} as RAILS_ROOT in error traces *Nicholas Seckar* - -* Remove ARStore session fingerprinting *Nicholas Seckar* - -* Fix obscure bug in ARStore *Nicholas Seckar* - -* Added TextHelper#strip_tags for removing HTML tags from a string (using HTMLTokenizer) #2229 *marcin@junkheap.net* - -* Added a reader for flash.now, so it's possible to do stuff like flash.now[:alert] ||= 'New if not set' #2422 *Caio Chassot* - - -## 1.10.2 (October 26th, 2005) ## - -* Reset template variables after using render_to_string *Stefan Kaes* - -* Expose the session model backing CGI::Session - -* Abbreviate RAILS_ROOT in traces - - -## 1.10.1 (October 19th, 2005) ## - -* Update error trace templates *Nicholas Seckar* - -* Stop showing generated routing code in application traces *Nicholas Seckar* - - -## 1.10.0 (October 16th, 2005) ## - -* Make string-keys locals assigns optional. Add documentation describing depreciated state *Stefan Kaes* - -* Improve line number detection for template errors *Nicholas Seckar* - -* Update/clean up documentation (rdoc) - -* Upgrade to Prototype 1.4.0_rc0 *Sam Stephenson* - -* Added assert_vaild. Reports the proper AR error messages as fail message when the passed record is invalid *Tobias Lütke* - -* Add temporary support for passing locals to render using string keys *Nicholas Seckar* - -* Clean up error pages by providing better backtraces *Nicholas Seckar* - -* Raise an exception if an attempt is made to insert more session data into the ActiveRecordStore data column than the column can hold. #2234. *justin@textdrive.com* - -* Removed references to assertions.rb from actionpack assert's backtraces. Makes error reports in functional unit tests much less noisy. *Tobias Lütke* - -* Updated and clarified documentation for JavaScriptHelper to be more concise about the various options for including the JavaScript libs. *Thomas Fuchs* - -* Hide "Retry with Breakpoint" button on error pages until feature is functional. *David Heinemeier Hansson* - -* Fix Request#host_with_port to use the standard port when Rails is behind a proxy. *Nicholas Seckar* - -* Escape query strings in the href attribute of URLs created by url_helper. #2333 *Michael Schuerig * - -* Improved line number reporting for template errors *Nicholas Seckar* - -* Added :locals support for render :inline #2463 *mdabney@cavoksolutions.com* - -* Unset the X-Requested-With header when using the xhr wrapper in functional tests so that future requests aren't accidentally xhr'ed #2352 *me@julik.nl, Sam Stephenson* - -* Unescape paths before writing cache to file system. #1877. *Damien Pollet* - -* Wrap javascript_tag contents in a CDATA section and add a cdata_section method to TagHelper #1691 *Michael Schuerig, Sam Stephenson* - -* Misc doc fixes (typos/grammar/etc). #2445. *coffee2code* - -* Speed improvement for session_options. #2287. *Stefan Kaes* - -* Make cacheing binary files friendly with Windows. #1975. *Rich Olson* - -* Convert boolean form options form the tag_helper. #809. *Michael Schuerig * - -* Fixed that an instance variable with the same name as a partial should be implicitly passed as the partial :object #2269 *court3nay* - -* Update Prototype to V1.4.0_pre11, script.aculo.us to [2502] *Thomas Fuchs* - -* Make assert_tag :children count appropriately. Closes #2181. *jamie@bravenet.com* - -* Forced newer versions of RedCloth to use hard breaks *David Heinemeier Hansson* - -* Added new scriptaculous options for auto_complete_field #2343 *Manfred Stienstra* - -* Don't prepend the asset host if the string is already a fully-qualified URL - -* Updated to script.aculo.us V1.5.0_rc2 and Prototype to V1.4.0_pre7 *Thomas Fuchs* - -* Undo condition change made in [2345] to prevent normal parameters arriving as StringIO. - -* Tolerate consecutive delimiters in query parameters. #2295 *darashi@gmail.com* - -* Streamline render process, code cleaning. Closes #2294. *skae* - -* Keep flash after components are rendered. #2291 *Rick Olson, Scott* - -* Shorten IE file upload path to filename only to match other browsers. #1507 *court3nay* - -* Fix open/save dialog in IE not opening files send with send_file/send_data, #2279 *Thomas Fuchs* - -* Fixed that auto_discovery_link_tag couldn't take a string as the URL *David Heinemeier Hansson* - -* Fixed problem with send_file and WEBrick using stdout #1812 *David Heinemeier Hansson* - -* Optimized tag_options to not sort keys, which is no longer necessary when assert_dom_equal and friend is available #1995 *skae* - -* Added assert_dom_equal and assert_dom_not_equal to compare tags generated by the helpers in an order-indifferent manner #1995 *skae* - -* Fixed that Request#domain caused an exception if the domain header wasn't set in the original http request #1795 *Michael Koziarski* - -* Make the truncate() helper multi-byte safe (assuming $KCODE has been set to something other than "NONE") #2103 - -* Add routing tests from #1945 *ben@groovie.org* - -* Add a routing test case covering #2101 *Nicholas Seckar* - -* Cache relative_url_root for all webservers, not just Apache #2193 *skae* - -* Speed up cookie use by decreasing string copying #2194 *skae* - -* Fixed access to "Host" header with requests made by crappy old HTTP/1.0 clients #2124 *Marcel Molina Jr.* - -* Added easy assignment of fragment cache store through use of symbols for included stores (old way still works too) - - Before: - ActionController::Base.fragment_cache_store = - ActionController::Base::Caching::Fragments::FileStore.new("/path/to/cache/directory") - - After: - ActionController::Base.fragment_cache_store = :file_store, "/path/to/cache/directory" - -* Added ActionController::Base.session_store=, session_store, and session_options to make it easier to tweak the session options (instead of going straight to ActionController::CgiRequest::DEFAULT_SESSION_OPTIONS) - -* Added TextHelper#cycle to cycle over an array of values on each hit (useful for alternating row colors etc) #2154 *dave-ml@dribin.org* - -* Ensure that request.path never returns nil. Closes #1675 *Nicholas Seckar* - -* Add ability to specify Route Regexps for controllers. Closes #1917. *Sebastian Kanthak* - -* Provide Named Route's hash methods as helper methods. Closes #1744. *Nicholas Seckar, Steve Purcell* - -* Added :multipart option to ActiveRecordHelper#form to make it possible to add file input fields #2034 *jstirk@oobleyboo.com* - -* Moved auto-completion and in-place editing into the Macros module and their helper counterparts into JavaScriptMacrosHelper - -* Added in-place editing support in the spirit of auto complete with ActionController::Base.in_place_edit_for, JavascriptHelper#in_place_editor_field, and Javascript support from script.aculo.us #2038 *Jon Tirsen* - -* Added :disabled option to all data selects that'll make the elements inaccessible for change #2167, #253 *eigentone* - -* Fixed that TextHelper#auto_link_urls would include punctuation in the links #2166, #1671 *eigentone* - -* Fixed that number_to_currency(1000, {:precision => 0})) should return "$1,000", instead of "$1,000." #2122 *sd@notso.net* - -* Allow link_to_remote to use any DOM-element as the parent of the form elements to be submitted #2137 *erik@ruby-lang.nl*. Example: - - - - - <%= link_to_remote 'Save', :update => "row023", - :submit => "row023", :url => {:action => 'save_row'} %> - - -* Fixed that render :partial would fail when :object was a Hash (due to backwards compatibility issues) #2148 *Sam Stephenson* - -* Fixed JavascriptHelper#auto_complete_for to only include unique items #2153 *Thomas Fuchs* - -* Fixed all AssetHelper methods to work with relative paths, such that javascript_include_tag('stdlib/standard') will look in /javascripts/stdlib/standard instead of '/stdlib/standard/' #1963 - -* Avoid extending view instance with helper modules each request. Closes #1979 - -* Performance improvements to CGI methods. Closes #1980 *Stefan Kaes* - -* Added :post option to UrlHelper#link_to that makes it possible to do POST requests through normal ahref links using Javascript - -* Fixed overwrite_params - -* Added ActionController::Base.benchmark and ActionController::Base.silence to allow for easy benchmarking and turning off the log - -* Updated vendor copy of html-scanner to support better xml parsing - -* Added :popup option to UrlHelper#link_to #1996 *gabriel.gironda@gmail.com*. Examples: - - link_to "Help", { :action => "help" }, :popup => true - link_to "Busy loop", { :action => "busy" }, :popup => ['new_window', 'height=300,width=600'] - -* Drop trailing \000 if present on RAW_POST_DATA (works around bug in Safari Ajax implementation) #918 - -* Fix observe_field to fall back to event-based observation if frequency <= 0 #1916 *Michael Schubert* - -* Allow use of the :with option for submit_to_remote #1936 *jon@instance-design.co.uk* - -* AbstractRequest#domain returns nil when host is an ip address #2012 *Kevin Clark* - -* ActionController documentation update #2051 *François Beausoleil* - -* Yield @content_for_ variables to templates #2058 *Sam Stephenson* - -* Make rendering an empty partial collection behave like :nothing => true #2080 *Sam Stephenson* - -* Add option to specify the singular name used by pagination. - -* Use string key to obtain action value. Allows indifferent hashes to be disabled. - -* Added ActionView::Base.cache_template_loading back. - -* Rewrote compiled templates to decrease code complexity. Removed template load caching in favour of compiled caching. Fixed template error messages. *Nicholas Seckar* - -* Fix Routing to handle :some_param => nil better. *Nicholas Seckar, Luminas* - -* Add support for :include with pagination (subject to existing constraints for :include with :limit and :offset) #1478 *Michael Schubert* - -* Prevent the benchmark module from blowing up if a non-HTTP/1.1 request is processed - -* Added :use_short_month option to select_month helper to show month names as abbreviations - -* Make link_to escape the javascript in the confirm option #1964 *nicolas.pouillard@gmail.com* - -* Make assert_redirected_to properly check URL's passed as strings #1910 *Scott Barron* - -* Make sure :layout => false is always used when rendering inside a layout - -* Use raise instead of assert_not_nil in Test::Unit::TestCase#process to ensure that the test variables (controller, request, response) have been set - -* Make sure assigns are built for every request when testing #1866 - -* Allow remote_addr to be queried on TestRequest #1668 - -* Fixed bug when a partial render was passing a local with the same name as the partial - -* Improved performance of test app req/sec with ~10% refactoring the render method #1823 *Stefan Kaes* - -* Improved performance of test app req/sec with 5-30% through a series of Action Pack optimizations #1811 *Stefan Kaes* - -* Changed caching/expiration/hit to report using the DEBUG log level and errors to use the ERROR log level instead of both using INFO - -* Added support for per-action session management #1763 - -* Improved rendering speed on complicated templates by up to 100% (the more complex the templates, the higher the speedup) #1234 *Stefan Kaes*. This did necessasitate a change to the internals of ActionView#render_template that now has four parameters. Developers of custom view handlers (like Amrita) need to update for that. - -* Added options hash as third argument to FormHelper#input, so you can do input('person', 'zip', :size=>10) #1719 *jeremye@bsa.ca.gov* - -* Added Base#expires_in(seconds)/Base#expires_now to control HTTP content cache headers #1755 *Thomas Fuchs* - -* Fixed line number reporting for Builder template errors #1753 *piotr* - -* Fixed assert_routing so that testing controllers in modules works as expected *Nicholas Seckar, Rick Olson* - -* Fixed bug with :success/:failure callbacks for the JavaScriptHelper methods #1730 *court3nay/Thomas Fuchs* - -* Added named_route method to RouteSet instances so that RouteSet instance methods do not prevent certain names from being used. *Nicholas Seckar* - -* Fixed routes so that routes which do not specify :action in the path or in the requirements have a default of :action => 'index', In addition, fixed url generation so that :action => 'index' does not need to be provided for such urls. *Nicholas Seckar, Markjuh* - -* Worked around a Safari bug where it wouldn't pass headers through if the response was zero length by having render :nothing return ' ' instead of '' - -* Fixed Request#subdomains to handle "foo.foo.com" correctly - - -## 1.9.1 (11 July, 2005) ## - -* Fixed that auto_complete_for didn't force the input string to lower case even as the db comparison was - -* Fixed that Action View should always use the included Builder, never attempt to require the gem, to ensure compatibility - -* Added that nil options are not included in tags, so tag("p", :ignore => nil) now returns

    not

    but that tag("p", :ignore => "") still includes it #1465 *Michael Schuerig* - -* Fixed that UrlHelper#link_to_unless/link_to_if used html_escape on the name if no link was to be applied. This is unnecessary and breaks its use with images #1649 *joergd@pobox.com* - -* Improved error message for DoubleRenderError - -* Fixed routing to allow for testing of *path components #1650 *Nicholas Seckar* - -* Added :handle as an option to sortable_element to restrict the drag handle to a given class #1642 *thejohnny* - -* Added a bunch of script.aculo.us features #1644, #1677, #1695 *Thomas Fuchs* - * Effect.ScrollTo, to smoothly scroll the page to an element - * Better Firefox flickering handling on SlideUp/SlideDown - * Removed a possible memory leak in IE with draggables - * Added support for cancelling dragging my hitting ESC - * Added capability to remove draggables/droppables and redeclare sortables in dragdrop.js (this makes it possible to call sortable_element on the same element more than once, e.g. in AJAX returns that modify the sortable element. all current sortable 'stuff' on the element will be discarded and the sortable will be rebuilt) - * Always reset background color on Effect.Highlight; this make change backwards-compatibility, to be sure include style="background-color:(target-color)" on your elements or else elements will fall back to their CSS rules (which is a good thing in most circumstances) - * Removed circular references from element to prevent memory leaks (still not completely gone in IE) - * Changes to class extension in effects.js - * Make Effect.Highlight restore any previously set background color when finishing (makes effect work with CSS classes that set a background color) - * Fixed myriads of memory leaks in IE and Gecko-based browsers *David Zülke* - * Added incremental and local autocompleting and loads of documentation to controls.js *Ivan Krstic* - * Extended the auto_complete_field helper to accept tokens option - * Changed object extension mechanism to favor Object.extend to make script.aculo.us easily adaptable to support 3rd party libs like IE7.js *David Zülke* - -* Fixed that named routes didn't use the default values for action and possible other parameters #1534 *Nicholas Seckar* - -* Fixed JavascriptHelper#visual_effect to use camelize such that :blind_up will work #1639 *pelletierm@eastmedia.net* - -* Fixed that a SessionRestoreError was thrown if a model object was placed in the session that wasn't available to all controllers. This means that it's no longer necessary to use the 'model :post' work-around in ApplicationController to have a Post model in your session. - - -## 1.9.0 (6 July, 2005) ## - -* Added logging of the request URI in the benchmark statement (makes it easy to grep for slow actions) - -* Added javascript_include_tag :defaults shortcut that'll include all the default javascripts included with Action Pack (prototype, effects, controls, dragdrop) - -* Cache several controller variables that are expensive to calculate #1229 *Stefan Kaes* - -* The session class backing CGI::Session::ActiveRecordStore may be replaced with any class that duck-types with a subset of Active Record. See docs for details #1238 *Stefan Kaes* - -* Fixed that hashes was not working properly when passed by GET to lighttpd #849 *Nicholas Seckar* - -* Fixed assert_template nil will be true when no template was rendered #1565 *maceywj@telus.net* - -* Added :prompt option to FormOptions#select (and the users of it, like FormOptions#select_country etc) to create "Please select" style descriptors #1181 *Michael Schuerig* - -* Added JavascriptHelper#update_element_function, which returns a Javascript function (or expression) that'll update a DOM element according to the options passed #933 *mortonda@dgrmm.net*. Examples: - - <%= update_element_function("products", :action => :insert, :position => :bottom, :content => "

    New product!

    ") %> - - <% update_element_function("products", :action => :replace, :binding => binding) do %> -

    Product 1

    -

    Product 2

    - <% end %> - -* Added :field_name option to DateHelper#select_(year|month|day) to deviate from the year/month/day defaults #1266 *Marcel Molina Jr.* - -* Added JavascriptHelper#draggable_element and JavascriptHelper#drop_receiving_element to facilitate easy dragging and dropping through the script.aculo.us libraries #1578 *Thomas Fuchs* - -* Added that UrlHelper#mail_to will now also encode the default link title #749 *f.svehla@gmail.com* - -* Removed the default option of wrap=virtual on FormHelper#text_area to ensure XHTML compatibility #1300 *thomas@columbus.rr.com* - -* Adds the ability to include XML CDATA tags using Builder #1563 *Josh Knowles*. Example: - - xml.cdata! "some text" # => - -* Added evaluation of SCRIPT blocks in content returned to Ajax calls #1577 *Thomas Fuchs/court3nay/Sean Treadway* - -* Directly generate paths with a leading slash instead of tacking it on later. #1543 *Nicholas Seckar* - -* Fixed errant parameter modification in functional tests. #1542 *Nicholas Seckar* - -* Routes fail with leading slash #1540 *Nicholas Seckar* - -* Added support for graceful error handling of Ajax calls #1217 *Jamis Buck/Thomas Fuchs*. Example: - - link_to_remote( - "test", - :url => { :action => "faulty" }, - :update => { :success => "good", :failure => "bad" }, - 403 => "alert('Forbidden- got ya!')", - 404 => "alert('Nothing there...?')", - :failure => "alert('Unkown error ' + request.status)") - -* Attempt to explicitly flush the output at the end of CgiProcess#out - -* Fixed assert_redirected_to to handle absolute controller paths properly #1472 *Rick Olson/Nicholas Seckar* - -* Added event-based observations when frequency is not set on observe_field/form #1474 *flash@vanklinkenbergsoftware.nl* - -* Added script.aculo.us Javascripts (controls.js, dragdrop.js, effects.js) (NEEDS MORE DESCRIPTION) #1509 *Thomas Fuchs* - -* Fixed prototype to consider all fields it doesn't know as text (such as Safari's search) just like the browser in its serialization #1497 *Sean Treadway* - -* Improved performance of Routes generation by a factor of 5 #1434 *Nicholas Seckar* - -* Added named routes (NEEDS BETTER DESCRIPTION) #1434 *Nicholas Seckar* - -* Improved AbstractRequest documentation #1483 *court3nay* - -* Added ActionController::Base.allow_concurrency to control whether the application is thread-safe, so multi-threaded servers like WEBrick knows whether to apply a mutex around the performance of each action. Turned off by default. EXPERIMENTAL FEATURE. - -* Added TextHelper#word_wrap(text, line_length = 80) #1449 *tuxie@dekadance.se* - -* Added a fall-through action for form_remote_tag that'll be used in case Javascript is unavailable #1459 *Scott Barron*. Example: - - form_remote_tag :html => { :action => url_for(:controller => "some", :action => "place") } - -* Added :xhr => true/false option to verify so you can ensure that a request is coming from an Ajax call or not #1464 *Thomas Fuchs* - -* Added tag_options as a third parameter to AssetHelper#auto_discovery_link_tag to control options like the title of the link #1430 *Kevin Clark* - -* Added option to pass in parameters to CaptureHelper#capture, so you can create more advanced view helper methods #1466 *duane.johnson@gmail.com*. Example: - - <% show_calendar(:year => 2005, :month => 6) do |day, options| %> - <% options[:bgcolor] = '#dfd' if 10..15.include? day %> - [<%= day %>] - <% end %> - -* Changed the default name of the input tag generated by FormTagHelper#submit_tag from "submit" to "commit" so it doesn't clash with form.submit() calls in Javascript #1271 - -* Fixed relative urls support for lighttpd #1048 *Nicholas Seckar/maznawak@nerim.net* - -* Correct distance_of_time_in_words for integer arguments and make the second arg optional, treating the first arg as a duration in seconds. #1458 *madrobby * - -* Fixed query parser to deal gracefully with equal signs inside keys and values #1345 *gorou*. - Example: /?sig=abcdef=:foobar=&x=y will pass now. - -* Added Cuba to country list #1351 *todd* - -* Fixed radio_button to work with numeric values #1352 *demetrius* - -* Added :extension option to NumberHelper#number_to_phone #1361 *delynnb* - -* Added button_to as a form-based solution to deal with harmful actions that should be hidden behind POSTs. This makes it just as easy as link_to to create a safe trigger for actions like destroy, although it's limited by being a block element, the fixed look, and a no-no inside other forms. #1371 *tom@moertel.com* - -* Fixed image_tag so an exception is not thrown just because the image is missing and alt value can't be generated #1395 *Marcel Molina Jr.* - -* Added a third parameter to TextHelper#auto_link called href_options for specifying additional tag options on the links generated #1401 *tyler.kovacs@gmail.com*. Example: auto_link(text, :all, { :target => "_blank" }) to have all the generated links open in a new window. - -* Fixed TextHelper#highlight to return the text, not nil, if the phrase is blank #1409 *Patrick Lenz* - -* Fixed TagHelper such that :name and 'name' keys in the options doesn't result in two attributes #1455 *take_tk* - -* Ensure that helpers are only available to the controllers where they are defined and their subclasses. #1394 *kdole@tamu.edu* - -* render("foo/bar") works with a layout again - -* Fixed double-singularization on scaffolded pagination call (Address would be turned into Addres) #1216, #1404 *nilsga* - -* Removed the require hack used by functional testing to work around an earlier bug in rake. - -* Allow distance_of_time_in_words to work with any value that responds to #to_time (like dates) #969 - -* Support :render option for :verify #1440 *Tobias Lütke* - -* Updated vendor copy of html-scanner lib to 0.5.2, for bug fixes and optimizations. The :content option may be used as expected--to find a tag whose textual content is a particular value--in assert_tag, now. - -* Changed test requests to come from 0.0.0.0 instead of 127.0.0.1 such that they don't trigger debugging screens on exceptions, but instead call rescue_action_in_public - -* Modernize scaffolding to match the generator: use the new render method and change style from the warty @params["id"] to the sleek params[:id]. #1367 - -* Include :id in the action generated by the form helper method. Then, for example, the controller can do Model.find(params[:id]) for both edit and update actions. Updated scaffolding to take advantage. #1367 - -* Add assertions with friendly messages to TestCase#process to ensure that @controller, @request, and @response are set. #1367 - -* Arrays, hashes sent via multipart posts are converted to strings #1032 *dj@omelia.org, me@julik.nl* - -* render(:layout => true) is a synonym for render(:layout => nil) - -* Make sure the benchmarking render method always returns the output of the render. - -* render(:action), render(:template) and render() are the only three calls that default to using a layout. All other render calls assume :layout => false. This also fixes send_file, which was applying a layout if one existed for the current action. - -* verify with :redirect_to won't redirect if a redirect or render has already been performed #1350 - -* render(:partial => true) is identical to the behavior of the deprecated render_partial() - -* Fixed render(:partial => "...") to use an empty Hash for the local assigns #1365 - -* Fixed Caching::Fragments::FileStore.delete to not raise an exception if the delete fails. - -* Deprecated all render_* methods in favor of consolidating all rendering behavior in Base#render(options). This enables more natural use of combining options, such as layouts. Examples: - - +---------------------------------------------------------------+-------------------------------------------------------+ - | BEFORE | AFTER | - +---------------------------------------------------------------+-------------------------------------------------------+ - | render_with_layout "weblog/show", "200 OK", "layouts/dialog" | render :action => "show", :layout => "dialog" | - | render_without_layout "weblog/show" | render :action => "show", :layout => false | - | render_action "error", "404 Not Found" | render :action => "error", :status => "404 Not Found" | - | render_template "xml.div('stuff')", "200 OK", :rxml | render :inline => "xml.div('stuff')", :type => :rxml | - | render_text "hello world!" | render :text => "hello world!" | - | render_partial_collection "person", @people, nil, :a => 1 | render :partial => "person", :collection => @people, | - | | :locals => { :a => 1 } | - +---------------------------------------------------------------+-------------------------------------------------------+ - -* Deprecated redirect_to_path and redirect_to_url in favor of letting redirect_to do the right thing when passed either a path or url. - -* Fixed use of an integer as return code for renders, so render_text "hello world", 404 now works #1327 - -* Fixed assert_redirect_to to work with redirect_to_path #869 *Nicholas Seckar* - -* Fixed escaping of :method option in remote_form_tag #1218 *Rick Olson* - -* Added Serbia and Montenegro to the country_select #1239 *todd@robotcoop.com* - -* Fixed Request#remote_ip in testing #1251 *Jeremy Kemper* - -* Fixed that compute_public_path should recognize external URLs, so image_tag("http://www.example.com/images/icon.gif") is not prefixed with the relative url path #1254 *victor-ronr-trac@carotena.net* - -* Added support for descending year values in DateHelper#select_year, like select_year(Date.today, :start_year => 2005, :end_year => 1900), which would count down from 2005 to 1900 instead of the other way #1274 *nwoods@mail.com* - -* Fixed that FormHelper#checkbox should return a checked checkbox if the value is the same as checked_value #1286 *Florian Weber* - -* Fixed Form.disable in Prototype #1317 *Wintermute* - -* Added accessors to logger, params, response, session, flash, and headers from the view, so you can write <% logger.info "stuff" %> instead of <% @logger.info "others" %> -- more consistent with the preferred way of accessing these attributes and collections from the controller - -* Added support for POST data in form of YAML or XML, which is controller through the Content-Type header. Example request: - - Content-Type: application/xml - HelloWorld - - ...is the same as: - - Content-Type: application/x-yaml - --- - item: - content: HelloWorld - - ...is the same as: - - item[content]=HelloWorld - - Which in the end turns into { "item" => { "content" => "HelloWorld" } }. This makes it a lot easier to publish REST web services on top of your regular actions (as they won't care). - - Example Curl call: - - curl -H 'Content-Type: application/xml' -d 'KillMeMore' http://www.example.com/service - - You can query to find out whether a given request came through as one of these types with: - - request.post_format? (:url_encoded, :xml or :yaml) - - request.formatted_post? (for either xml or yaml) - - request.xml_post? - - request.yaml_post? - -* Added bundling of XmlSimple by Maik Schmidt - -* Fixed that render_partial_collection should always return a string (and not sometimes an array, despite <%= %> not caring) - -* Added TextHelper#sanitize that can will remove any Javascript handlers, blocks, and forms from an input of HTML. This allows for use of HTML on public sites, but still be free of XSS issues. #1277 *Jamis Buck* - -* Fixed the HTML scanner used by assert_tag where a infinite loop could be caused by a stray less-than sign in the input #1270 *Jamis Buck* - -* Added functionality to assert_tag, so you can now do tests on the siblings of a node, to assert that some element comes before or after the element in question, or just to assert that some element exists as a sibling #1226 *Jamis Buck* - -* Added better error handling for regexp caching expiration - -* Fixed handling of requests coming from unknown HTTP methods not to kill the server - -* Added that both AssetHelper#stylesheet_link_tag and AssetHelper#javascript_include_tag now accept an option hash as the last parameter, so you can do stuff like: stylesheet_link_tag "style", :media => "all" - -* Added FormTagHelper#image_submit_tag for making submit buttons that uses images - -* Added ActionController::Base.asset_host that will then be used by all the asset helpers. This enables you to easily offload static content like javascripts and images to a separate server tuned just for that. - -* Fixed action/fragment caching using the filestore when a directory and a file wanted to use the same name. Now there's a .cache prefix that sidesteps the conflict #1188 *imbcmdth@hotmail.com* - -* Fixed missing id uniqueness in FormTag#radio_button #1207 *Jarkko Laine* - -* Fixed assert_redirected_to to work with :only_path => false #1204 *Alisdair McDiarmid* - -* Fixed render_partial_collection to output an empty string instead of nil when handed an empty array #1202 *Ryan Carver* - -* Improved the speed of regular expression expirations for caching by a factor of 10 #1221 *Jamis Buck* - -* Removed dumping of template assigns on the rescue page as it would very easily include a ton of data making page loads take seconds (and the information was rarely helpful) #1222 - -* Added BenchmarkHelper that can measure the execution time of a block in a template and reports the result to the log. Example: - - <% benchmark "Notes section" do %> - <%= expensive_notes_operation %> - <% end %> - - Will add something like "Notes section (0.345234)" to the log. - -* Added ActionController::Caching::Sweeper as an improved an easier to use sweeper. The new sweepers work on a single-step approach instead of two-steps like the old ones. Before - - def after_save(record) - @list = record.is_a?(List) ? record : record.list - end - - def filter(controller) - controller.expire_page(:controller => "lists", :action => %w( show public feed ), :id => @list.id) - controller.expire_action(:controller => "lists", :action => "all") - @list.shares.each { |share| controller.expire_page(:controller => "lists", :action => "show", :id => share.url_key) } - end - - ..after: - - def after_save(record) - list = record.is_a?(List) ? record : record.list - expire_page(:controller => "lists", :action => %w( show public feed ), :id => list.id) - expire_action(:controller => "lists", :action => "all") - list.shares.each { |share| expire_page(:controller => "lists", :action => "show", :id => share.url_key) } - end - - The new sweepers can also observe on the actions themselves by implementing methods according to (before|after)_$controller_$action. Example of a callback that'll be called after PagesController#update_title has been performed: - - def after_pages_update_title - expire_fragment(%r{pages/#{controller.assigns["page"].id}/.*}) - end - - Note that missing_method is delegated to the controller instance, which is assigned in a before filter. This means that you can call expire_fragment instead of @controller.expire_fragment. - -* Added that Fragments#expire_fragment now accepts as a regular expression as the name thereby deprecating expire_matched_fragments - -* Fixed that fragments shouldn't use the current host and the path as part of the key like pages does - -* Added conditions to around_filters just like before_filter and after_filter - - -## 1.8.1 (20th April, 2005) ## - -* Added xml_http_request/xhr method for simulating XMLHttpRequest in functional tests #1151 *Sam Stephenson*. Example: - - xhr :post, :index - -* Fixed that Ajax.Base.options.asynchronous wasn't being respected in Ajax.Request (thanks Jon Casey) - -* Fixed that :get, :post, and the others should take a flash array as the third argument just like process #1144 *rails@cogentdude.com* - -* Fixed a problem with Flash.now - -* Fixed stringification on all assigned hashes. The sacrifice is that assigns[:person] won't work in testing. Instead assigns["person"] or assigns(:person) must be used. In other words, the keys of assigns stay strings but we've added a method-based accessor to appease the need for symbols. - -* Fixed that rendering a template would require a connection to the database #1146 - - -## 1.8.0 (19th April, 2005) ## - -* Added assert_tag and assert_no_tag as a much improved alternative to the deprecated assert_template_xpath_match #1126 *Jamis Buck* - -* Deprecated the majority of all the testing assertions and replaced them with a much smaller core and access to all the collections the old assertions relied on. That way the regular test/unit assertions can be used against these. Added documentation about how to use it all. - -* Added a wide range of new Javascript effects: - * Effect.Puff zooms the element out and makes it smoothly transparent at the same time, giving a "puff" illusion #996 *thomas@fesch.at* - After the animation is completed, the display property will be set to none. - This effect will work on relative and absolute positioned elements. - - * Effect.Appear as the opposite of Effect.Fade #990 *thomas@fesch.at* - You should return elements with style="display:none;" or a like class for this to work best and have no chance of flicker. - - * Effect.Squish for scaling down an element and making it disappear at the end #972 *thomas@fesch.at* - - * Effect.Scale for smoothly scaling images or text up and down #972 *thomas@fesch.at* - - * Effect.Fade which smoothly turns opacity from 100 to 0 and then hides the element #960 *thomas@fesch.at* - -* Added Request#xml_http_request? (and an alias xhr?) to that'll return true when the request came from one of the Javascript helper methods (Ajax). This can be used to give one behavior for modern browsers supporting Ajax, another to old browsers #1127 *Sam Stephenson* - -* Changed render_partial to take local assigns as the second parameter instead of an explicit object and then the assigns. So the API changes from: - - <%= render_partial "account", person, "rules" => regulations.rules %> - - ...to: - - <%= render_partial "account", :account => person, :rules => regulations.rules %> - - The old API will still work, though, and render_partial "account" will still assume :account => @account. - -* Added support for web servers that use PATH_INFO instead of REQUEST_URI like IIS #1014 *BradG/Nicholas Seckar* - -* Added graceful handling of PUT, DELETE, and OPTIONS requests for a complete coverage of REST functionality #1136 *Josh Knowles* - -* Fixed that you can now pass an alternative :href option to link_to_function/remote in order to point to somewhere other than # if the javascript fails or is turned off. You can do the same with form_remote_tag by passing in :action. #1113 *Sam Stephenson* - -* Fixed DateHelper to return values on the option tags such that they'll work properly in IE with form_remote_tag #1024 *Scott Raymond* - -* Fixed FormTagHelper#check_box to respect checked #1049 *DelynnB* - -* Added that render_partial called from a controller will use the action name as default #828 *Dan Peterson* - -* Added Element.toggle, Element.show, and Element.hide to the prototype javascript library. Toggle.display has been deprecated, but will still work #992 *Lucas Carlson* - -* Added that deleting a cookie should not just set it to an empty string but also instantly expire it #1118 *todd@robotcoop.com* - -* Added AssetTagHelper#image_path, AssetTagHelper#javascript_path, and AssetTagHelper#stylesheet_path #1110 *Larry Halff* - -* Fixed url_for(nil) in functional tests #1116 *Alisdair McDiarmid* - -* Fixed error handling of broken layouts #1115 *Michael Schubert* - -* Added submit_to_remote that allows you to trigger an Ajax form submition at the click of the submission button, which allows for multiple targets in a single form through the use of multiple submit buttons #930 *yrashk@gmail.com* - -* Fixed pagination to work with joins #1034 *scott@sigkill.org* - -* Fixed that *rest parameter in map.connect couldn't accept an empty list #1037 *Dee Zsombor* - -* Added :confirm option to link_to_remote just like link_to has #1082 *yrashk@fp.org.ua* - -* Added minute_step as an option to select_minute (and the helpers that use it) to jump in larger increments than just 1 minute. At 15, it would return 0, 15, 30, 45 options #1085 *ordwaye@evergreen.edu* - -* Fixed that an exception would be thrown when an empty form was submitted #1090 *jan@ulbrich-boerwang.de* - -* Moved TextHelper#human_size to NumberHelper#number_to_human_size, but kept an deprecated alias to the old method name - -* Fixed that the content-type for some browsers could include an additional \r which made wonky things happen #1067 *Thomas Fuchs* - -* Fixed that radio buttons shouldn't have a default size attribute #1074 *hendrik@mans.de* - -* Added ActionView::Helpers::InstanceTag::DEFAULT_RADIO_OPTIONS that contains a hash of default options for radio buttons #1074 *hendrik@mans.de* - -* Fixed that in some circumstances controllers outside of modules may have hidden ones inside modules. For example, admin/content might have been hidden by /content. #1075 *Nicholas Seckar* - -* Added JavascriptHelper#periodically_call_remote in order to create areas of a page that update automatically at a set interval #945 *Jon Tirsen* - -* Fixed Cache#expire_matched_fragments that couldn't recognize the difference between string and url_for options #1030 *Stefan Kaes* - -* Added simulation of @request.request_uri in functional tests #1038 *Jamis Buck* - -* Fixed autolinking to work better in more cases #1013 *Jamis Buck* - -* Added the possible of using symbols in form helpers that relate to instance variables like text_field :account, :name in addition to text_field "account", "name"' - -* Fixed javascript_include_tag to output type instead of language and conform to XHTML #1018 *Rick Olson* - -* Added NumberHelper for common string representations like phone number, currency, and percentage #1015 *DeLynn* - -* Added pagination for scaffolding (10 items per page) #964 *mortonda@dgrmm.net* - -* Added assert_no_cookie and fixed assert_cookie_equal to deal with non-existing cookies #979 *Jeremy Kemper* - -* Fixed :overwrite_param so it doesn't delete but reject elements from @request.parameters #982 *raphinou@yahoo.com* - -* Added :method option to verify for ensuring that either GET, POST, etc is allowed #984 *Jamis Buck* - -* Added options to set cc, bcc, subject, and body for UrlHelper#mail_to #966 *DeLynn* - -* Fixed include_blank for select_hour/minute/second #527 *edward@debian.org* - -* Improved the message display on the exception handler pages #963 *Johan Sorensen* - -* Fixed that on very rare occasions, webrick would raise a NoMethodError: private method 'split' called for nil #1001 *Flurin Egger* - -* Fixed problem with page caching #958 *Rick Olson* - - -## 1.7.0 (27th March, 2005) ## - -* Added ActionController::Base.page_cache_extension for setting the page cache file extension (the default is .html) #903 *Andreas* - -* Fixed "bad environment variable value" exception caused by Safari, Apache, and Ajax calls #918 - -* Fixed that pagination_helper would ignore :params #947 *Sebastian Kanthak* - -* Added :owerwrite_params back to url_for and friends -- it was AWL since the introduction of Routes #921 *raphinou* - -* Added :position option to link_to_remote/form_remote_tag that can be either :before, :top, :bottom, or :after and specifies where the return from the method should be inserted #952 *Matthew McCray/Sam Stephenson* - -* Added Effect.Highlight to prototype.js to do Yellow Fade Technique (of 37signals' fame) on any container #952 *Sam Stephenson/court3nay* - -* Added include_seconds option as the third parameter to distance_of_time_in_words which will render "less than a minute" in higher resolution ("less than 10 seconds" etc) #944 *thomas@fesch.at* - -* Added fourth option to process in test cases to specify the content of the flash #949 *Jamis Buck* - -* Added Verifications that allows you to specify preconditions to actions in form of statements like verify :only => :update_post, :params => "admin_privileges", :redirect_to => { :action => "settings" }, which ensure that the update_post action is only called if admin_privileges is available as a parameter -- otherwise the user is redirected to settings. #897 *Jamis Buck* - -* Fixed Form.Serialize for the JavascriptHelper to also seriliaze password fields #934 *dweitzman@gmail.com* - -* Added JavascriptHelper#escape_javascript as a public method (was private) and made it escape both single and double quotes and new lines #940 *mortonda@dgrmm.net* - -* Added trailing_slash option to url_for, so you can generate urls ending in a slash. Note that is currently not recommended unless you need it for special reasons since it breaks caching #937 *stian@grytoyr.net* - -* Added expire_matched_fragments(regular_expression) to clear out a lot of fragment caches at once #927 *Rick Olson* - -* Fixed the problems with : and ? in file names for fragment caches on Windows #927 *Rick Olson* - -* Added TextHelper#human_size for formatting file sizes, like human_size(1234567) => 1.2 MB #943 *thomas@fesch.at* - -* Fixed link_to :confirm #936 *Nicholas Seckar* - -* Improved error reporting especially around never shallowing exceptions. Debugging helpers should be much easier now #980 *Nicholas Seckar* - -* Fixed Toggle.display in prototype.js #902 *Lucas Carlson* - - -## 1.6.0 (22th March, 2005) ## - -* Added a JavascriptHelper and accompanying prototype.js library that opens the world of Ajax to Action Pack with a large array of options for dynamically interacting with an application without reloading the page #884 *Sam Stephenson/David* - -* Added pagination support through both a controller and helper add-on #817 *Sam Stephenson* - -* Fixed routing and helpers to make Rails work on non-vhost setups #826 *Nicholas Seckar/Tobias Lütke* - -* Added a much improved Flash module that allows for finer-grained control on expiration and allows you to flash the current action #839 *Caio Chassot*. Example of flash.now: - - class SomethingController < ApplicationController - def save - ... - if @something.save - # will redirect, use flash - flash[:message] = 'Save successful' - redirect_to :action => 'list' - else - # no redirect, message is for current action, use flash.now - flash.now[:message] = 'Save failed, review' - render_action 'edit' - end - end - end - -* Added to_param call for parameters when composing an url using url_for from something else than strings #812 *Sam Stephenson*. Example: - - class Page -   def initialize(number) -     @number = number -   end -   # ... -   def to_param -     @number.to_s -   end - end - - You can now use instances of Page with url_for: - - class BarController < ApplicationController -   def baz -     page = Page.new(4) -     url = url_for :page => page # => "http://foo/bar/baz?page=4" -   end - end - -* Fixed form helpers to query Model#id_before_type_cast instead of Model#id as a temporary workaround for Ruby 1.8.2 warnings #818 *DeLynn B* - -* Fixed TextHelper#markdown to use blank? instead of empty? so it can deal with nil strings passed #814 *Johan Sörensen* - -* Added TextHelper#simple_format as a non-dependency text presentation helper #814 *Johan Sörensen* - -* Added that the html options disabled, readonly, and multiple can all be treated as booleans. So specifying disabled => :true will give disabled="disabled". #809 *mindel* - -* Added path collection syntax for Routes that will gobble up the rest of the url and pass it on to the controller #830 *rayners*. Example: - - map.connect 'categories/*path_info', :controller => 'categories', :action => 'show' - - A request for /categories/top-level-cat, would give @params[:path_info] with "top-level-cat". - A request for /categories/top-level-cat/level-1-cat, would give @params[:path_info] with "top-level-cat/level-1-cat" and so forth. - - The @params[:path_info] return is really an array, but where to_s has been overwritten to do join("/"). - -* Fixed options_for_select on selected line issue #624 *Florian Weber* - -* Added CaptureHelper with CaptureHelper#capture and CaptureHelper#content_for. See documentation in helper #837 *Tobias Lütke* - -* Fixed :anchor use in url_for #821 *Nicholas Seckar* - -* Removed the reliance on PATH_INFO as it was causing problems for caching and inhibited the new non-vhost support #822 *Nicholas Seckar* - -* Added assigns shortcut for @response.template.assigns to controller test cases *Jeremy Kemper*. Example: - - Before: - - def test_list - assert_equal 5, @response.template.assigns['recipes'].size - assert_equal 8, @response.template.assigns['categories'].size - end - - After: - - def test_list - assert_equal 5, assigns(:recipes).size - assert_equal 8, assigns(:categories).size - end - -* Added TagHelper#image_tag and deprecated UrlHelper#link_image_to (recommended approach is to combine image_tag and link_to instead) - -* Fixed textilize to be resilient to getting nil parsed (by using Object#blank? instead of String#empty?) - -* Fixed that the :multipart option in FormTagHelper#form_tag would be ignored *Yonatan Feldman* - - -## 1.5.1 (7th March, 2005) ## - -* Fixed that the routes.rb file wouldn't be found on symlinked setups due to File.expand_path #793 *piotr@t-p-l.com* - -* Changed ActiveRecordStore to use Marshal instead of YAML as the latter proved troublesome in persisting circular dependencies. Updating existing applications MUST clear their existing session table from data to start using this updated store #739 *Jamis Buck* - -* Added shortcut :id assignment to render_component and friends (before you had to go through :params) #784 *Lucas Carlson* - -* Fixed that map.connect should convert arguments to strings #780 *Nicholas Seckar* - -* Added UrlHelper#link_to_if/link_to_unless to enable other conditions that just link_to_unless_current #757 *mindel* - -* Fixed that single quote was not escaped in a UrlHelper#link_to javascript confirm #549 *Scott Barron* - -* Removed the default border on link_image_to (it broke xhtml strict) -- can be specified with :border => 0 #517 *?/caleb* - -* Fixed that form helpers would treat string and symbol keys differently in html_options (and possibly create duplicate entries) #112 *Jeremy Kemper* - -* Fixed that broken pipe errors (clients disconnecting in mid-request) could bring down a fcgi process - -* Added the original exception message to session recall errors (so you can see which class wasnt required) - -* Fixed that RAILS_ROOT might not be defined when AP was loaded, so do a late initialization of the ROUTE_FILE #761 *Scott Barron* - -* Fix request.path_info and clear up LoadingModule behavior #754 *Nicholas Seckar* - -* Fixed caching to be aware of extensions (so you can cache files like api.wsdl or logo.png) #734 *Nicholas Seckar* - -* Fixed that Routes would raise NameErrors if a controller component contains characters that are not valid constant names #733 *Nicholas Seckar* - -* Added PATH_INFO access from the request that allows urls like the following to be interpreted by rails: http://www.example.com/dispatcher.cgi/controller/action -- that makes it possible to use rails as a CGI under lighttpd and would also allow (for example) Rublog to be ported to rails without breaking existing links to Rublog-powered blogs. #728 *Jamis Buck* - -* Fixed that caching the root would result in .html not index.html #731, #734 *alisdair/Nicholas Seckar* - - -## 1.5.0 (24th February, 2005) ## - -* Added Routing as a replacement for mod_rewrite pretty urls [Nicholas Seckar]. Read more in ActionController::Base.url_for and on http://manuals.rubyonrails.com/read/book/9 - -* Added components that allows you to call other actions for their rendered response while execution another action. You can either delegate the entire response rendering or you can mix a partial response in with your other content. Read more on http://manuals.rubyonrails.com/read/book/14 - -*  Fixed that proxy IPs do not follow all RFC1918 nets #251 *caleb@aei-tech.com* - -* Added Base#render_to_string to parse a template and get the result back as a string #479 - -* Fixed that send_file/data can work even if render* has been called before in action processing to render the content of a file to be send for example #601 - -* Added FormOptionsHelper#time_zone_select and FormOptionsHelper#time_zone_options_for_select to work with the new value object TimeZone in Active Support #688 *Jamis Buck* - -* Added FormHelper#file_field and FormTagHelper#file_field_tag for creating file upload fields - -* Added :order option for date_select that allows control over the order in which the date dropdowns is used and which of them should be used #619 [Tim Bates]. Examples: - - date_select("post", "written_on", :order => [:day, :month, :year]) - date_select("user", "birthday", :order => [:month, :day]) - -* Added ActionView::Base.register_template_handler for easy integration of an alternative template language to ERb and Builder. See test/controller/custom_handler_test.rb for a usage example #656 *Jamis Buck* - -* Added AssetTagHelper that provides methods for linking a HTML page together with other assets, such as javascripts, stylesheets, and feeds. - -* Added FormTagHelper that provides a number of methods for creating form tags that doesn't rely on conventions with an object assigned to the template like FormHelper does. With the FormTagHelper, you provide the names and values yourself. - -* Added Afghanistan, Iran, and Iraq to the countries list used by FormOptions#country_select and FormOptions#country_options_for_select - -* Renamed link_to_image to link_image_to (since thats what it actually does) -- kept alias for the old method name - -* Fixed textilize for RedCloth3 to keep doing hardbreaks - -* Fixed that assert_template_xpath_matches did not indicate when a path was not found #658 *Eric Hodel* - -* Added TextHelper#auto_link to turn email addresses and urls into ahrefs - -* Fixed that on validation errors, scaffold couldn't find template #654 *mindel* - -* Added Base#hide_action(*names) to hide public methods from a controller that would otherwise have been callable through the URL. For the majority of cases, its preferred just to make the methods you don't want to expose protected or private (so they'll automatically be hidden) -- but if you must have a public method, this is a way to make it uncallable. Base#hidden_actions retrieve the list of all hidden actions for the controller #644 *Nicholas Seckar* - -* Fixed that a bunch of methods from ActionController::Base was accessible as actions (callable through a URL) when they shouldn't have been #644 *Nicholas Seckar* - -* Added UrlHelper#current_page?(options) method to check if the url_for options passed corresponds to the current page - -* Fixed https handling on other ports than 443 *Alan Gano* - -* Added follow_redirect method for functional tests that'll get-request the redirect that was made. Example: - - def test_create_post - post :create, "post" => { "title" => "Exciting!" } - assert_redirected_to :action => "show" - - follow_redirect - assert_rendered_file "post/show" - end - - Limitation: Only works for redirects to other actions within the same controller. - -* Fixed double requiring of models with the same name as the controller - -* Fixed that query params could be forced to nil on a POST due to the raw post fix #562 *moriq@moriq.com* - -* Fixed that cookies shouldn't be frozen in TestRequest #571 *Eric Hodel* - - -## 1.4.0 (January 25th, 2005) ## - -* Fixed problems with ActiveRecordStore under the development environment in Rails - -* Fixed the ordering of attributes in the xml-decleration of Builder #540 *woeye* - -* Added @request.raw_post as a convenience access to @request.env['RAW_POST_DATA'] #534 *Tobias Lütke* - -* Added support for automatic id-based indexing for lists of items #532 [dblack]. Example: - - <% @students.each do |@student| %> - <%= text_field "student[]", "first_name", :size => "20" %> - <%= text_field "student[]", "last_name" %> - <%= text_field "student[]", "grade", :size => "5" %> - <% end %> - - ...would produce, for say David Black with id 123 and a grace of C+: - - - - - -* Added :application_prefix to url_for and friends that makes it easier to setup Rails in non-vhost environments #516 *Jamis Buck* - -* Added :encode option to mail_to that'll allow you to masquarede the email address behind javascript or hex encoding #494 *Lucas Carlson* - -* Fixed that the content-header was being set to application/octet_stream instead of application/octet-stream on send_date/file *Alexey* - -* Removed the need for passing the binding when using CacheHelper#cache - -* Added TestResponse#binary_content that'll return as a string the data sent through send_data/send_file for testing #500 *Alexey* - -* Added @request.env['RAW_POST_DATA'] for people who need access to the data before Ruby's CGI has parsed it #505 *Jeremy Kemper* - -* Fixed that a default fragment store wan't being set to MemoryStore as intended. - -* Fixed that all redirect and render calls now return true, so you can use the pattern of "do and return". Example: - - def show - redirect_to(:action => "login") and return unless @person.authenticated? - render_text "I won't happen unless the person is authenticated" - end - -* Added that renders and redirects called in before_filters will have the same effect as returning false: stopping the chain. Example: - - class WeblogController - before_filter { |c| c.send(:redirect_to_url("http://www.farfaraway.com/")}) } - - def hello - render_text "I will never be called" - end - end - - -* Added that only one render or redirect can happen per action. The first call wins and subsequent calls are ignored. Example: - - def do_something - redirect_to :action => "elsewhere" - render_action "overthere" - end - - Only the redirect happens. The rendering call is simply ignored. - - -## 1.3.1 (January 18th, 2005) ## - -* Fixed a bug where cookies wouldn't be set if a symbol was used instead of a string as the key - -* Added assert_cookie_equal to assert the contents of a named cookie - -* Fixed bug in page caching that prevented it from working at all - - -## 1.3.0 (January 17th, 2005) ## - -* Added an extensive caching module that offers three levels of granularity (page, action, fragment) and a variety of stores. - Read more in ActionController::Caching. - -* Added the option of passing a block to ActiveRecordHelper#form in order to add more to the auto-generated form #469 *dom@sisna.com* - - form("entry", :action => "sign") do |form| - form << content_tag("b", "Department") - form << collection_select("department", "id", @departments, "id", "name") - end - -* Added arrays as a value option for params in url_for and friends #467 [Eric Anderson]. Example: - - url_for(:controller => 'user', :action => 'delete', :params => { 'username' => %( paul john steve ) } ) - # => /user/delete?username[]=paul&username[]=john&username[]=steve - -* Fixed that controller tests can now assert on the use of cookies #466 *Alexey* - -* Fixed that send_file would "remember" all the files sent by adding to the headers again and again #458 *Jeremy Kemper* - -* Fixed url rewriter confusion when the controller or action name was a substring of the controller_prefix or action_prefix - -* Added conditional layouts like layout "weblog_standard", :except => :rss #452 *Marcel Molina Jr.* - -* Fixed that MemCacheStore wasn't included by default and added default MemCache object pointing to localhost #447 *Lucas Carlson* - -* Added fourth argument to render_collection_of_partials that allows you to specify local_assigns -- just like render_partial #432 *Ryan Davis* - -* Fixed that host would choke when cgi.host returned nil #432 *Tobias Lütke* - -* Added that form helpers now take an index option #448 *Tim Bates* - - Example: - text_field "person", "name", "index" => 3 - - Becomes: - - -* Fixed three issues with retrying breakpoints #417 *Florian Gross* - - 1. Don't screw up pages that use multiple values for the same parameter (?foo=bar&foo=qux was converted to ?foo=barqux) - 2. Don't screw up all forms when you click the "Retry with Breakpoint" link multiple times instead of reloading - (This caused the parameters to be added multiple times for GET forms leading to trouble.) - 3. Don't add ?BP-RETRY=1 multiple times - -* Added that all renders and redirects now return false, so they can be used as the last line in before_filters to stop execution. - - Before: - def authenticate - unless @session[:authenticated] - redirect_to :controller => "account", :action => "login" - return false - end - end - - After: - def authenticate - redirect_to(:controller => "account", :action => "login") unless @session[:authenticated] - end - -* Added conditional filters #431 [Marcel Molina Jr.]. Example: - - class JournalController < ActionController::Base - # only require authentication if the current action is edit or delete - before_filter :authorize, :only_on => [ :edit, :delete ] - - private - def authorize - # redirect to login unless authenticated - end - end - -* Added Base#render_nothing as a cleaner way of doing render_text "" when you're not interested in returning anything but an empty response. - -* Added the possibility of passing nil to UrlHelper#link_to to use the link itself as the name - - -## 1.2.0 (January 4th, 2005) ## - -* Added MemCacheStore for storing session data in Danga's MemCache system *Bob Cottrell* - Depends on: MemCached server (http://www.danga.com/memcached/), MemCache client (http://raa.ruby-lang.org/project/memcache/) - -* Added thread-safety to the DRbStore #66, #389 *Ben Stiglitz* - -* Added DateHelper#select_time and DateHelper#select_second #373 *Scott Baron* - -* Added :host and :protocol options to url_for and friends to redirect to another host and protocol than the current. - -* Added class declaration for the MissingFile exception #388 *Kent Sibilev* - -* Added "short hypertext note with a hyperlink to the new URI(s)" to redirects to fulfill compliance with RFC 2616 (HTTP/1.1) section 10.3.3 #397 *Tim Bates* - -* Added second boolean parameter to Base.redirect_to_url and Response#redirect to control whether the redirect is permanent or not (301 vs 302) #375 *Hodel* - -* Fixed redirects when the controller and action is named the same. Still haven't fixed same controller, module, and action, though #201 *Josh Peek* - -* Fixed problems with running multiple functional tests in Rails under 1.8.2 by including hack for test/unit weirdness - -* Fixed that @request.remote_ip didn't work in the test environment #369 *Bruno Mattarollo* - - -## 1.1.0 ## - -* Added search through session to clear out association caches at the end of each request. This makes it possible to place Active Record objects - in the session without worrying about stale data in the associations (the main object is still subject to caching, naturally) #347 *Tobias Lütke* - -* Added more informative exception when using helper :some_helper and the helper requires another file that fails, you'll get an - error message tells you what file actually failed to load, rather than falling back on assuming it was the helper file itself #346 *dblack* - -* Added use of *_before_type_cast for all input and text fields. This is helpful for getting "100,000" back on a integer-based - validation where the value would normally be "100". - -* Added Request#port_string to get something like ":8080" back on 8080 and "" on 80 (or 443 with https). - -* Added Request#domain (returns string) and Request#subdomains (returns array). - -* Added POST support for the breakpoint retries, so form processing that raises an exception can be retried with the original request *Florian Gross* - -* Fixed regression with Base#reset_session that wouldn't use the DEFAULT_SESSION_OPTIONS *adam@the-kramers.net* - -* Fixed error rendering of rxml documents to not just swallow the exception and return 0 (still not guessing the right line, but hey) - -* Fixed that textilize and markdown would instantiate their engines even on empty strings. This also fixes #333 *Ulysses* - -* Fixed UrlHelper#link_to_unless so it doesn't care if the id is a string or fixnum *Ryan Davis* - - -## 1.0.1 ## - -* Fixed a bug that would cause an ApplicationController to require itself three times and hence cause filters to be run three times *evl* - - -## 1.0 ## - -* Added that controllers will now attempt to require a model dependency with their name and in a singular attempt for their name. - So both PostController and PostsController will automatically have the post.rb model required. If no model is found, no error is raised, - as it is then expected that no match is available and the programmer will have included his own models. - -* Fixed DateHelper#date_select so that you can pass include_blank as an option even if you don't use start_year and end_year #59 *what-a-day* - -* Added that controllers will now search for a layout in $template_root/layouts/$controller_name.r(html|xml), so PostsController will look - for layouts/posts.rhtml or layouts/posts.rxml and automatically configure this layout if found #307 *Marcel Molina Jr.* - -* Added FormHelper#radio_button to work with radio buttons like its already possible with check boxes *Michael Koziarski* - -* Added TemplateError#backtrace that makes it much easier to debug template errors from unit and functional tests - -* Added display of error messages with scaffolded form pages - -* Added option to ERB templates to swallow newlines by using <% if something -%> instead of just <% if something %>. Example: - - class SomeController < ApplicationController - <% if options[:scaffold] %> - scaffold :<%= singular_name %> - <% end %> - helper :post - - ...produces this on post as singular_name: - - class SomeController < ApplicationController - - scaffold :post - - helper :post - - ...where as: - - class SomeController < ApplicationController - <% if options[:scaffold] -%> - scaffold :<%= singular_name %> - <% end -%> - helper :post - - ...produces: - - class SomeController < ApplicationController - scaffold :post - helper :post - - *This undocumented gem for ERb was uncovered by bitsweat* - -* Fixed CgiRequest so that it'll now accept session options with Symbols as keys (as the documentation points out) *Suggested by Andreas* - -* Added that render_partial will always by default include a counter with value 1 unless there is a counter passed in via the - local_assigns hash that overrides it. As a result, render_collection_of_partials can still be written in terms of render_partial - and partials that make use of a counter can be called without problems from both render_collection_of_partials as well as - render_partial #295 *Marcel Molina Jr.* - -* Fixed CgiRequest#out to fall back to #write if $stdout doesn't have #syswrite *Jeremy Kemper* - -* Fixed all helpers so that they use XHTML compliant double quotes for values instead of single quotes *htonl/Jeremy Kemper* - -* Added link_to_image(src, options = {}, html_options = {}). Documentation: - - Creates a link tag to the image residing at the +src+ using an URL created by the set of +options+. See the valid options in - link:classes/ActionController/Base.html#M000021. It's also possible to pass a string instead of an options hash to - get a link tag that just points without consideration. The html_options works jointly for the image and ahref tag by - letting the following special values enter the options on the image and the rest goes to the ahref: - - ::alt: If no alt text is given, the file name part of the +src+ is used (capitalized and without the extension) - ::size: Supplied as "XxY", so "30x45" becomes width="30" and height="45" - ::align: Sets the alignment, no special features - - The +src+ can be supplied as a... - * full path, like "/my_images/image.gif" - * file name, like "rss.gif", that gets expanded to "/images/rss.gif" - * file name without extension, like "logo", that gets expanded to "/images/logo.png" - -* Fixed to_input_field_tag so it no longer explicitly uses InstanceTag.value if value was specified in the options hash *evl* - -* Added the possibility of having validate be protected for assert_(in)valid_column #263 *Tobias Lütke* - -* Added that ActiveRecordHelper#form now calls url_for on the :action option. - -* Added all the HTTP methods as alternatives to the generic "process" for functional testing #276 *Tobias Lütke*. Examples: - - # Calls Controller#miletone with a GET request - process :milestone - - # Calls Controller#miletone with a POST request that has parameters - post :milestone, { "name" => "David" } - - # Calls Controller#milestone with a HEAD request that has both parameters and session data - head :milestone, { "id" => 1 }, { "user_id" => 23 } - - This is especially useful for testing idiomatic REST web services. - -* Added proper handling of HEAD requests, so that content isn't returned (Request#head? added as well) #277 *Eric Hodel* - -* Added indifference to whether @headers["Content-Type"], @headers["Content-type"], or @headers["content-type"] is used. - -* Added TestSession#session_id that returns an empty string to make it easier to functional test applications that doesn't use - cookie-based sessions #275 *jcf* - -* Fixed that cached template loading would still check the file system to see if the file existed #258 *Andreas Schwarz* - -* Added options to tailor header tag, div id, and div class on ActiveRecordHelper#error_messages_for *Josh Peek* - -* Added graceful handling of non-alphanumeric names and misplaced brackets in input parameters *Jeremy Kemper* - -* Added a new container for cookies that makes them more intuative to use. The old methods of cookie and @cookies have been deprecated. - - Examples for writing: - - cookies["user_name"] = "david" # => Will set a simple session cookie - cookies["login"] = { "value" => "XJ-122", "expires" => Time.now + 360} # => Will set a cookie that expires in 1 hour - - Examples for reading: - - cookies["user_name"] # => "david" - cookies.size # => 2 - - Read more in ActionController::Cookies - - NOTE: If you were using the old accessor (cookies instead of @cookies), this could potentially break your code -- if you expect a full cookie object! - -* Added the opportunity to defined method_missing on a controller which will handle all requests for actions not otherwise defined #223 *timb* - -* Fixed AbstractRequest#remote_ip for users going through proxies - Patch #228 *Eric Hodel* - -* Added Request#ssl? which is shorthand for @request.protocol == "https://" - -* Added the choice to call form_tag with no arguments (resulting in a form posting to current action) *Jeremy Kemper* - -* Upgraded to Builder 1.2.1 - -* Added :module as an alias for :controller_prefix to url_for and friends, so you can do redirect_to(:module => "shop", :controller => "purchases") - and go to /shop/purchases/ - -* Added support for controllers in modules through @params["module"]. - -* Added reloading for dependencies under cached environments like FastCGI and mod_ruby. This makes it possible to use those environments for development. - This is turned on by default, but can be turned off with ActionController::Base.reload_dependencies = false in production environments. - - NOTE: This will only have an effect if you use the new model, service, and observer class methods to mark dependencies. All libraries loaded through - require will be "forever" cached. You can, however, use ActionController::Base.load_or_require("library") to get this behavior outside of the new - dependency style. - -* Added that controllers will automatically require their own helper if possible. So instead of doing: - - class MsgController < ApplicationController - helper :msg - end - - ...you can just do: - - class MsgController < ApplicationController - end - -* Added dependencies_on(layer) to query the dependencies of a controller. Examples: - - MsgController.dependencies_on(:model) # => [ :post, :comment, :attachment ] - MsgController.dependencies_on(:service) # => [ :notification_service ] - MsgController.dependencies_on(:observer) # => [ :comment_observer ] - -* Added a new dependency model with the class methods model, service, and observer. Example: - - class MsgController < ApplicationController - model :post, :comment, :attachment - service :notification_service - observer :comment_observer - end - - These new "keywords" remove the need for explicitly calling 'require' in most cases. The observer method even instantiates the - observer as well as requiring it. - -* Fixed that link_to would escape & in the url again after url_for already had done so - - -## 0.9.5 (28) ## - -* Added helper_method to designate that a given private or protected method you should available as a helper in the view. *Jeremy Kemper* - -* Fixed assert_rendered_file so it actually verifies if that was the rendered file *htonl* - -* Added the option for sharing partial spacer templates just like partials themselves *radsaq* - -* Fixed that Russia was named twice in country_select *alexey* - -* Fixed request_origin to use remote_ip instead of remote_addr *Jeremy Kemper* - -* Fixed link_to breakage when nil was passed for html_options *alexey* - -* Fixed redirect_to on a virtual server setup with apache with a port other than the default where it would forget the port number *seanohalpin* - -* Fixed that auto-loading webrick on Windows would cause file uploads to fail *Jeremy Kemper* - -* Fixed issues with sending files on WEBrick by setting the proper binmode *Jeremy Kemper* - -* Added send_data as an alternative to send_file when the stream is not read off the filesystem but from a database or generated live *Jeremy Kemper* - -* Added a new way to include helpers that doesn't require the include hack and can go without the explicit require. *Jeremy Kemper* - - Before: - - module WeblogHelper - def self.included(controller) #:nodoc: - controller.ancestors.include?(ActionController::Base) ? controller.add_template_helper(self) : super - end - end - - require 'weblog_helper' - class WeblogController < ActionController::Base - include WeblogHelper - end - - After: - - module WeblogHelper - end - - class WeblogController < ActionController::Base - helper :weblog - end - -* Added a default content-type of "text/xml" to .rxml renders *Ryan Platte* - -* Fixed that when /controller/index was requested by the browser, url_for would generates wrong URLs *Ryan Platte* - -* Fixed a bug that would share cookies between users when using FastCGI and mod_ruby *The Robot Co-op* - -* Added an optional third hash parameter to the process method in functional tests that takes the session data to be used *alexey* - -* Added UrlHelper#mail_to to make it easier to create mailto: style ahrefs - -* Added better error messages for layouts declared with the .rhtml extension (which they shouldn't) *geech* - -* Added another case to DateHelper#distance_in_minutes to return "less than a minute" instead of "0 minutes" and "1 minute" instead of "1 minutes" - -* Added a hidden field to checkboxes generated with FormHelper#check_box that will make sure that the unchecked value (usually 0) - is sent even if the checkbox is not checked. This relieves the controller from doing custom checking if the checkbox wasn't - checked. BEWARE: This might conflict with your run-on-the-mill work-around code. *Tobias Lütke* - -* Fixed error_message_on to just use the first if more than one error had been added *Marcel Molina Jr.* - -* Fixed that URL rewriting with /controller/ was working but /controller was not and that you couldn't use :id on index *geech* - -* Fixed a bug with link_to where the :confirm option wouldn't be picked up if the link was a straight url instead of an option hash - -* Changed scaffolding of forms to use