[
  {
    "path": ".dockerignore",
    "content": ".byebug_history\n.cache\n.git/*\n.github/*\n.vscode/*\n.yardoc/*\n/.bundle\nconfig/postal/*\ndoc/*\nDockerfile\nlog/*\nnode_modules\nProcfile.local\nProcfile*\npublic/assets\nstorage/*\ntmp/*\nvendor/bundle\nvendor/bundle\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: 🐛 Bug report\nabout: Create a report to help us improve Postal and fix issues.\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n## Describe the bug\n\nA clear and concise description of what the bug is.\n\n## To Reproduce\n\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n## Expected behaviour\n\nA clear and concise description of what you expected to happen.\n\n## Screenshots\n\nIf applicable, add screenshots to help explain your problem.\n\n## Environment details \n\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n - Type [e.g. desktop, mobile etc...]\n\n## Additional information/context\n\nAdd any other context about the problem here. It is particularily useful to include log extracts (after removing private information). \n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: 💻 Installation help\n    url: https://github.com/postalhq/postal/discussions/new?category=Installation-help\n    about: If you have questions about running Postal on your servers, use GitHub Discussions.\n\n  - name: 🙏 Help with using Postal\n    url: https://github.com/postalserver/postal/discussions/new?category=Help-with-using-Postal\n    about: If you need help using Postal's features, use GitHub Discussions.\n\n  - name: 🦊 Feature Suggestions\n    url: https://github.com/postalhq/postal/discussions/new?category=Feature-Suggestions\n    about: Suggest a new feature that should be added to Postal.\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "---\nname: CI\non: [push]\n\njobs:\n  release-please:\n    runs-on: ubuntu-latest\n    if: github.ref == 'refs/heads/main'\n    outputs:\n      release_created: ${{ steps.release-please.outputs.release_created }}\n      tag_name: ${{ steps.release-please.outputs.tag_name }} # e.g. v1.0.0\n      version: ${{ steps.release-please.outputs.version }} # e.g. 1.0.0\n      all: ${{ toJSON(steps.release-please.outputs) }}\n    steps:\n      - uses: google-github-actions/release-please-action@v3\n        id: release-please\n        with:\n          command: manifest\n\n  build:\n    name: CI Image Build\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - uses: docker/setup-buildx-action@v2\n      - uses: docker/login-action@v2\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - uses: docker/build-push-action@v4\n        with:\n          push: true\n          tags: ghcr.io/postalserver/postal:ci-${{ github.sha }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          target: ci\n          platforms: linux/amd64\n\n  test:\n    name: Test Suite\n    runs-on: ubuntu-latest\n    needs: build\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - uses: docker/login-action@v2\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - run: docker compose pull\n        env:\n          POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }}\n      - run: docker compose run postal sh -c 'bundle exec rspec'\n        env:\n          POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }}\n\n  release-branch:\n    name: Release (branch)\n    runs-on: ubuntu-latest\n    needs: [build]\n    if: >-\n      startsWith(github.ref, 'refs/heads/') &&\n      startsWith(github.ref, 'refs/heads/release-please-') != true &&\n      startsWith(github.ref, 'refs/heads/dependabot/') != true\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - uses: docker/setup-qemu-action@v3\n      - uses: docker/setup-buildx-action@v2\n      - uses: docker/login-action@v2\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - id: info\n        run: |\n          IMAGE=ghcr.io/postalserver/postal\n\n          REF=\"${GITHUB_REF#refs/heads/}\"\n          if [ -z \"$REF\" ]; then exit 1; fi\n\n          VER=\"$(git describe --tags 2>/dev/null)\"\n          echo \"version=${VER}\" >> \"$GITHUB_OUTPUT\"\n          echo \"branch=${REF}\" >> \"$GITHUB_OUTPUT\"\n\n          echo 'tags<<EOF' >> \"$GITHUB_OUTPUT\"\n          if [[ \"$REF\" == \"main\" ]]; then\n            echo \"${IMAGE}:latest\" >> \"$GITHUB_OUTPUT\"\n          else\n            echo \"${IMAGE}:branch-${REF}\" >> \"$GITHUB_OUTPUT\"\n          fi\n          echo 'EOF' >> \"$GITHUB_OUTPUT\"\n      - uses: docker/build-push-action@v4\n        with:\n          push: true\n          tags: ${{ steps.info.outputs.tags }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          target: full\n          platforms: linux/amd64\n          build-args: |\n            VERSION=${{ steps.info.outputs.version }}\n            BRANCH=${{ steps.info.outputs.branch }}\n\n  publish-image:\n    name: Publish Image\n    runs-on: ubuntu-latest\n    needs: [build, test, release-please]\n    if: ${{ needs.release-please.outputs.release_created }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: docker/setup-qemu-action@v3\n      - uses: docker/setup-buildx-action@v2\n      - uses: docker/login-action@v2\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - uses: docker/build-push-action@v4\n        with:\n          push: true\n          tags: |\n            ghcr.io/postalserver/postal:stable\n            ghcr.io/postalserver/postal:${{ needs.release-please.outputs.version }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          target: full\n          build-args: |\n            VERSION=${{ needs.release-please.outputs.version }}\n          platforms: linux/amd64\n"
  },
  {
    "path": ".github/workflows/close.yml",
    "content": "name: 'Close stale issues and PRs'\non:\n  schedule:\n    - cron: '30 1 * * *'\n  workflow_dispatch: \npermissions:\n  issues: write\n  pull-requests: write\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/stale@v9\n        with:\n          stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'\n          stale-pr-message: 'This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days.'\n          close-issue-message: 'This issue was closed because it has been stalled for 5 days with no activity.'\n          close-pr-message: 'This PR was closed because it has been stalled for 10 days with no activity.'\n\n          stale-issue-label: stale\n          stale-pr-label: stale\n          \n          days-before-issue-stale: 30\n          days-before-pr-stale: 45\n          days-before-issue-close: 5\n          days-before-pr-close: 10\n\n          exempt-all-assignees: true\n          exempt-all-milestones: true\n          exempt-issue-labels: bug,enhancement,docs,install,feature\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files for more about ignoring files.\n#\n# If you find yourself ignoring temporary files generated by your text editor\n# or operating system, you probably want to add a global ignore instead:\n#   git config --global core.excludesfile '~/.gitignore_global'\n\n# Ignore bundler config.\n/.bundle\n\n# Ignore all logfiles and tempfiles.\n/log/*\n/tmp/*\n!/log/.keep\n!/tmp/.keep\n\n# Ignore Byebug command history file.\n.byebug_history\n\nconfig/postal.yml\nconfig/smtp.cert\nconfig/smtp.key\nconfig/signing.key\nconfig/postal/**/*\n\nspec/config/postal.local.yml\n\npublic/assets\nvendor/bundle\n\nProcfile.local\nVERSION\nBRANCH\n\n.rubocop-https*\n.env*\n\n"
  },
  {
    "path": ".release-please-manifest.json",
    "content": "{\n  \".\": \"3.3.5\"\n}\n"
  },
  {
    "path": ".rubocop.yml",
    "content": "AllCops:\n  TargetRubyVersion: 3.0\n  NewCops: enable\n  Exclude:\n    - \"bin/*\"\n    - \"db/schema.rb\"\n    # Fixes missing gem exception when running Rubocop on GitHub Actions.\n    - \"vendor/bundle/**/*\"\n    - lib/tasks/auto_annotate_models.rake\n\n# Always use double quotes\nStyle/StringLiterals:\n  EnforcedStyle: double_quotes\n  AutoCorrect: true\n\n# We prefer arrays of symbols to look like an array of symbols.\n# For example: [:one, :two, :three] as opposed to %i[one two three]\nStyle/SymbolArray:\n  EnforcedStyle: brackets\n\n# There should always be empty lines inside a class. For example\n#\n#    class MyExample\n#\n#      def some_method\n#      end\n#\n#    end\nLayout/EmptyLinesAroundClassBody:\n  EnforcedStyle: empty_lines\n\n# We want to keep attr_* definitions separated on their own lines, rather than\n# all of them collapsed into a single attr_* call. The collapsed/grouped variant\n# is harder to read, and harder to see what's been changed in diffs.\nStyle/AccessorGrouping:\n  Enabled: false\n\n# Blocks are slightly different to classes, in these cases there should\n# not be new lines around the contents of the block.\n#\n#    proc do\n#      # Do something\n#    end\nLayout/EmptyLinesAroundBlockBody:\n  EnforcedStyle: no_empty_lines\n\n# Modules are the same as classes unless they're being used for namespacing\n# purposes in which case there should not be new lines.\nLayout/EmptyLinesAroundModuleBody:\n  EnforcedStyle: empty_lines_except_namespace\n\n# Space is required following -> when writing a lambda:\n#\n#    somethign = -> (var) { block }\nLayout/SpaceInLambdaLiteral:\n  EnforcedStyle: require_space\n\nLayout/FirstHashElementIndentation:\n  Enabled: false\n\n# We don't mind setting assignments in conditions so this has been disabled to\n# allow `if something = something_else` without worrying about brackets.\nLint/AssignmentInCondition:\n  Enabled: false\n\n# Top level documentation is quite rare...\nStyle/Documentation:\n  Enabled: false\n\n# We want to allow inner slashes in a regexp to be used when using /xxx/ form.\nStyle/RegexpLiteral:\n  AllowInnerSlashes: true\n\n# Blocks of if statements are perfectly fine and usually more readable than\n# putting everything onto a single line just because we can.\nStyle/IfUnlessModifier:\n  Enabled: false\n\n# We prefer assignments to happen within the condition rather than setting a\n# variable to the result of a condition.\nStyle/ConditionalAssignment:\n  EnforcedStyle: assign_inside_condition\n  IncludeTernaryExpressions: false\n\n# Empty methods should not be compacted onto a single line\nStyle/EmptyMethod:\n  EnforcedStyle: expanded\n\n# As above, just flag them.\nLint/UnusedBlockArgument:\n  AutoCorrect: false\n\n# While we don't want to make heavy use of get_ or set_ methods we do often need\n# to use these when we want to refer to actually getting or setting something\n# (usually from another API).\nNaming/AccessorMethodName:\n  Enabled: false\n\n# If we want a boolean called :true, we should be allowed that. These are likely\n# not mistakes.\nLint/BooleanSymbol:\n  Enabled: false\n\n# Using block.map(&:upcase) is not always the neatest way to show something. For\n# example if you have a block that just calls one thing, you don't want it\n# collapsed.\n#\n#    action do |user|\n#      user.delete\n#    end\n#\n# This should be action(&:delete) because it is not clear what is actually\n# happening without the context of knowing what the inner variable should be\n# called.\nStyle/SymbolProc:\n  Enabled: false\n\n# Allow a maxmium of 5 arguments and don't include keyword arguments\nMetrics/ParameterLists:\n  Max: 5\n  CountKeywordArgs: false\n\n# This cop checks for chaining of a block after another block that spans multiple lines.\nStyle/MultilineBlockChain:\n  Exclude:\n    - \"spec/**/*.rb\"\n\nStyle/TrailingCommaInArrayLiteral:\n  EnforcedStyleForMultiline: consistent_comma\n\nMetrics/AbcSize:\n  Enabled: false\n\nStyle/FrozenStringLiteralComment:\n  Enabled: true\n  SafeAutoCorrect: true\n\nNaming/PredicateName:\n  Enabled: false\n\nLayout/LineLength:\n  # We want to reduce this back down to 120 but there are a fair number of offences\n  # of this which need addressing individually and carefully. \n  Max: 200\n\nMetrics/PerceivedComplexity:\n  # As above, we want to enable this again in the future, but for now we'll just \n  # disable it entirely.\n  Enabled: false\n\nMetrics/CyclomaticComplexity:\n  # As above.\n  Enabled: false\n\nMetrics/MethodLength:\n  # As above.\n  Enabled: false\n\nMetrics/BlockNesting:\n  # As above.\n  Enabled: false\n\nStyle/StringConcatenation:\n  Enabled: false\n\nMetrics/BlockLength:\n  Enabled: false\n\nMetrics/ClassLength:\n  Enabled: false\n\nMetrics/ModuleLength:\n  Enabled: false\n\nLint/UnusedMethodArgument:\n  Enabled: false\n\nStyle/SpecialGlobalVars:\n  Enabled: false\n"
  },
  {
    "path": ".ruby-version",
    "content": "3.4.6\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# CHANGELOG\n\nThis file contains all the latest changes and updates to Postal.\n\n## [3.3.5](https://github.com/postalserver/postal/compare/3.3.4...3.3.5) (2026-02-01)\n\n\n### Bug Fixes\n\n* **deliveries:** escape delivery details to prevent HTML injection ([11419f9](https://github.com/postalserver/postal/commit/11419f99140e13688a9613cab3ee03f8d3cbae45))\n* **health_server:** use rackup handler instead of rack handler ([7c47422](https://github.com/postalserver/postal/commit/7c47422c865e738c4d6af0fed1cca4405288341f))\n* oidc scopes are invalid when concatenated ([#3332](https://github.com/postalserver/postal/issues/3332)) ([9c5f96a](https://github.com/postalserver/postal/commit/9c5f96ae90cf06dcd5db776806865752f667bd95))\n* typo in process logging ([#3212](https://github.com/postalserver/postal/issues/3212)) ([b7e5232](https://github.com/postalserver/postal/commit/b7e5232e077b3c9b7a999dcb6676fba0ec61458e))\n* typo in the credentials page ([fd3c7cc](https://github.com/postalserver/postal/commit/fd3c7ccdf6dc4ee0a76c9523cbd735159e4b8000))\n* update url for v2 config ([#3225](https://github.com/postalserver/postal/issues/3225)) ([e00098b](https://github.com/postalserver/postal/commit/e00098b8003cf37f2708f536871b3ade377aed2d))\n\n\n### Documentation\n\n* **process.rb:** add help about time unit used by metric ([#3339](https://github.com/postalserver/postal/issues/3339)) ([f5325c4](https://github.com/postalserver/postal/commit/f5325c49ff1152ad53eaaec98717ad3412d379ae))\n\n\n### Miscellaneous Chores\n\n* **deps:** upgrade puma, net-imap and other deps ([c03c44b](https://github.com/postalserver/postal/commit/c03c44b442a29aa9881c1e1aae60bead9776a6b6))\n* **dockerfile:** reduce container size ([86de372](https://github.com/postalserver/postal/commit/86de372382bd62bdd5d1372254f8817b0360bd56))\n* remove version from docker-compose.yml ([c78000c](https://github.com/postalserver/postal/commit/c78000ca8f2998aa04648f465060768db6467de6))\n* upgrade resolv to 0.6.2 ([d00d978](https://github.com/postalserver/postal/commit/d00d978872a96369544303d08f6a9d11cdf56b62))\n* upgrade to rails 7.1 and ruby 3.4 ([#3457](https://github.com/postalserver/postal/issues/3457)) ([ab6d443](https://github.com/postalserver/postal/commit/ab6d4430baa33a05f1aa66e776cc2a5bcaa0ede8))\n* upgrade uri gem to 1.0.3 ([f193b8e](https://github.com/postalserver/postal/commit/f193b8e77fc096382ab7aaa6a2c29641b4cb12df))\n\n## [3.3.4](https://github.com/postalserver/postal/compare/3.3.3...3.3.4) (2024-06-20)\n\n\n### Bug Fixes\n\n* fix `postal version` command ([4fa88ac](https://github.com/postalserver/postal/commit/4fa88acea0dececd0eae485506a2ad8268fbea59))\n* fix issue running message pruning task ([3a33e53](https://github.com/postalserver/postal/commit/3a33e53d843584757bb00898746aa059d7616db4))\n* raise NotImplementedError when no call method on a scheduled task ([2b0919c](https://github.com/postalserver/postal/commit/2b0919c1454eabea93db96f50ecbd8e36bb89f1f))\n\n## [3.3.3](https://github.com/postalserver/postal/compare/3.3.2...3.3.3) (2024-04-18)\n\n\n### Bug Fixes\n\n* **legacy-api:** allow _expansions to be provided as true to return all expansions ([39f704c](https://github.com/postalserver/postal/commit/39f704c256fc3e71a1dc009acc77796a1efffead)), closes [#2932](https://github.com/postalserver/postal/issues/2932)\n\n## [3.3.2](https://github.com/postalserver/postal/compare/3.3.1...3.3.2) (2024-03-21)\n\n\n### Code Refactoring\n\n* **versioning:** improve how current version and branch is determined and set ([07c6b31](https://github.com/postalserver/postal/commit/07c6b317f2b9dc04b6a8c88df1e6aa9e54597504))\n\n## [3.3.1](https://github.com/postalserver/postal/compare/3.3.0...3.3.1) (2024-03-21)\n\n\n### Bug Fixes\n\n* **smtp-sender:** ensure relays without a host are excluded ([3a56ec8](https://github.com/postalserver/postal/commit/3a56ec8a74950e0162d98f1af5f58a67a82d6455))\n* **smtp-sender:** fixes `SMTPSender.smtp_relays` ([b3264b9](https://github.com/postalserver/postal/commit/b3264b942776e254d3c351c94c435d172a514e18))\n\n\n### Miscellaneous Chores\n\n* **container:** add the branch name to the container ([bee5098](https://github.com/postalserver/postal/commit/bee509832edc151d97fe5bfc48c4973452873fc8))\n* **github-actions:** don't generate commit- tags ([d65bbe0](https://github.com/postalserver/postal/commit/d65bbe0579037c5df962a18134bc007f5159d7e5))\n* **github-actions:** don't run for dependabot or release-please PRs and fetch whole repo ([adaf2b0](https://github.com/postalserver/postal/commit/adaf2b07502e9ed91290873ad8465051c6fd814f))\n* **github-actions:** include a version string on branch-*/latest images ([64bc7dc](https://github.com/postalserver/postal/commit/64bc7dcf7c0a8e006ab6eb6e8b4a52ad5e7e6528))\n* **ui:** display branch in footer if present ([1823617](https://github.com/postalserver/postal/commit/18236171ebc398c157f2e61b15c7df9f91205284))\n\n\n### Code Refactoring\n\n* remove moonrope but maintain legacy API actions ([#2889](https://github.com/postalserver/postal/issues/2889)) ([4d9654d](https://github.com/postalserver/postal/commit/4d9654dac47d59c760be96388d0421de74d3e6ac))\n\n## [3.3.0](https://github.com/postalserver/postal/compare/3.2.2...3.3.0) (2024-03-18)\n\n\n### Features\n\n* **prometheus:** add `postal_message_queue_latency` metric ([ee8d829](https://github.com/postalserver/postal/commit/ee8d829a854f91e476167869cafe35c2d37bb314))\n* **worker:** allow number of threads to be configured ([7e2accc](https://github.com/postalserver/postal/commit/7e2acccd1ebd80750a3ebdb96cb5c36b5263cc24))\n* **worker:** scale connection pool with worker threads ([ea542a0](https://github.com/postalserver/postal/commit/ea542a0694b3465b04fd3ebc439837df414deb1e))\n\n\n### Bug Fixes\n\n* **message-dequeuer:** ability to disable batching ([4fcb9e9](https://github.com/postalserver/postal/commit/4fcb9e9a2e34be5aa4bdf13f0529f40e564b72b4))\n\n\n### Miscellaneous Chores\n\n* **config-docs:** update docs for latest oidc defaults ([364eba6](https://github.com/postalserver/postal/commit/364eba6c5fce2f08a36489f42856ad5024a2062c))\n* **config-docs:** update proxy protocol to mention v1 ([45dd8aa](https://github.com/postalserver/postal/commit/45dd8aaac56f15481cb7bf9081401cb28dc1e707))\n\n## [3.2.2](https://github.com/postalserver/postal/compare/3.2.1...3.2.2) (2024-03-14)\n\n\n### Bug Fixes\n\n* don't use authentication on org & server deletion ([be45652](https://github.com/postalserver/postal/commit/be456523dd3aacb5c3eb45c9261da97ebffe603c))\n* **smtp-server:** fixes proxy protocol ([9240612](https://github.com/postalserver/postal/commit/92406129cfcf1a06499a6f5aa18c73f1d6195793))\n\n\n### Miscellaneous Chores\n\n* allow config location message to be suppressed ([f760cdb](https://github.com/postalserver/postal/commit/f760cdb5a1d53e9c30ee495d129cbf12603a3cbd))\n* hide further config messages ([1c67f72](https://github.com/postalserver/postal/commit/1c67f72209c93404d7024ce3d15f6f54f2d707c4))\n* suppress config location on default-dkim-record ([aa76aae](https://github.com/postalserver/postal/commit/aa76aae2322af41af1bd60cfe1d69a11ac76324e))\n\n\n### Tests\n\n* add tests for the legacy API ([3d208d6](https://github.com/postalserver/postal/commit/3d208d632f4fc8a4adbfdb2bf4b377271eae6692))\n\n## [3.2.1](https://github.com/postalserver/postal/compare/3.2.0...3.2.1) (2024-03-13)\n\n\n### Bug Fixes\n\n* fixes `postal default-dkim-record` ([58dddeb](https://github.com/postalserver/postal/commit/58dddebeb81dc6fab945d2b10a91588eddc471c2))\n\n## [3.2.0](https://github.com/postalserver/postal/compare/3.1.1...3.2.0) (2024-03-13)\n\n\n### Features\n\n* add sha256 signatures to outgoing http requests ([#2874](https://github.com/postalserver/postal/issues/2874)) ([96d7365](https://github.com/postalserver/postal/commit/96d73653d7cb4dde1fbe74ccb3596147ef8cd9ed))\n* automatically remove queued messages with stale locks ([#2872](https://github.com/postalserver/postal/issues/2872)) ([d84152e](https://github.com/postalserver/postal/commit/d84152eb5df6f963426d6ba8d02d39b3c146c8a5))\n* openid connect support ([#2873](https://github.com/postalserver/postal/issues/2873)) ([5ed94f6](https://github.com/postalserver/postal/commit/5ed94f6f855735aa00544b2574dfb9e65d559a38))\n\n\n### Bug Fixes\n\n* **smtp-server:** add additional information to cram-md5 log entries ([9982bb8](https://github.com/postalserver/postal/commit/9982bb8c31ee4885d188666e2e8afdc218528df7))\n\n\n### Styles\n\n* **rubocop:** Style/TrailingCommaInArrayLiteral ([4e13577](https://github.com/postalserver/postal/commit/4e13577891dc827244abc6bf6d9ab4ee45860556))\n\n\n### Miscellaneous Chores\n\n* regenerate config docs ([5d8213a](https://github.com/postalserver/postal/commit/5d8213a98735f07fdf1700c7d01597654f41dbd0))\n\n## [3.1.1](https://github.com/postalserver/postal/compare/3.1.0...3.1.1) (2024-03-08)\n\n\n### Bug Fixes\n\n* don't override paths in dockerfile ([9399e32](https://github.com/postalserver/postal/commit/9399e3223467cdacd010e70b58ad6093e128213d))\n\n\n### Tests\n\n* **smtp-sender:** add more tests for AUTH LOGIN ([22dcd49](https://github.com/postalserver/postal/commit/22dcd4901f188915cf4b3c758c6f2fc637a4e1e3))\n\n## [3.1.0](https://github.com/postalserver/postal/compare/3.0.2...3.1.0) (2024-03-06)\n\n\n### Features\n\n* configurable trusted proxies for web requests ([3785c99](https://github.com/postalserver/postal/commit/3785c998513c634d225b489ccb43e926ce3f270a))\n\n\n### Bug Fixes\n\n* **message-dequeuer:** ensure SMTP endpoints are sent to SMTP sender appropriately ([e2d642c](https://github.com/postalserver/postal/commit/e2d642c0cbf443550886d90abc3a6edf3e4bc4fc)), closes [#2853](https://github.com/postalserver/postal/issues/2853)\n* **smtp-server:** listen on all interfaces by default ([d1e5b68](https://github.com/postalserver/postal/commit/d1e5b68200ea4b9710cc8714afb3271bad1f4f66)), closes [#2852](https://github.com/postalserver/postal/issues/2852)\n* **smtp-server:** remove ::ffff: from the start of ipv4 addresses ([0dc7359](https://github.com/postalserver/postal/commit/0dc7359431001c9ef1222913f8d1344093397596))\n* **smtp-server:** reset ansi sequence after logging ([9bf6152](https://github.com/postalserver/postal/commit/9bf6152060ffb8b611b66818c1d1ac7c929b7ffe))\n* **ui:** fixes typo on queue page ([33513a7](https://github.com/postalserver/postal/commit/33513a77c0df24d832ab7ed5237d68e2b1bde887))\n* **web-server:** allow for trusted proxies not be set ([4e1deb2](https://github.com/postalserver/postal/commit/4e1deb2d2aeb61d9dddb3729916411c94e73c1c6))\n\n\n### Styles\n\n* **rubocop:** use _ when not using a variable in helm config exporter ([2c20ba6](https://github.com/postalserver/postal/commit/2c20ba65f64ccb0f8174e3f523dedb3806478782))\n\n## [3.0.2](https://github.com/postalserver/postal/compare/3.0.1...3.0.2) (2024-03-05)\n\n\n### Bug Fixes\n\n* default to listening on all addresses when using legacy config ([48f6494](https://github.com/postalserver/postal/commit/48f6494240eb0374d5f865425b362e6f306b2653))\n\n\n### Miscellaneous Chores\n\n* removing arm64 support until we can get a reasonable build pipeline sorted ([e8e44f5](https://github.com/postalserver/postal/commit/e8e44f54b09426c8a04e466bc037851a0833d124))\n\n## [3.0.1](https://github.com/postalserver/postal/compare/3.0.0...3.0.1) (2024-03-05)\n\n\n### Bug Fixes\n\n* fix issue with sending mail when smtp relays are configured ([6dd6e29](https://github.com/postalserver/postal/commit/6dd6e29929c70eaa8b9d3b33c184996b0b6abb82))\n\n## [3.0.0](https://github.com/postalserver/postal/compare/2.3.2...3.0.0) (2024-03-04)\n\nThis version of Postal introduces a number of larger changes. Please be sure to follow the [upgrade guide](https://docs.postalserver.io/getting-started/upgrade-to-v3) when upgrading to Postal v3. Highlights include:\n\n* Removal of RabbitMQ dependency\n* Removal of `cron` and `requeuer` processes\n* Improved logging\n* Improved configuration\n* Adds prometheus metric exporters for workers and SMTP servers\n* Only accepted RFC-compliant end-of-DATA sequences (to avoid SMTP smuggling)\n\n### Features\n\n* add health server and prometheus metrics to worker ([a2eb70e](https://github.com/postalserver/postal/commit/a2eb70e))\n* add option to delay starting processes until all migrations are run ([1c5ff5a](https://github.com/postalserver/postal/commit/1c5ff5a))\n* add prometheus metrics to smtp server ([2e7b36c](https://github.com/postalserver/postal/commit/2e7b36c))\n* add prometheus metrics to worker ([bea7450](https://github.com/postalserver/postal/commit/bea7450))\n* more consistent logging ([044058d](https://github.com/postalserver/postal/commit/044058d))\n* new background work process ([dc8e895](https://github.com/postalserver/postal/commit/dc8e895))\n* new configuration system (and schema) (#2819) ([0163ac3](https://github.com/postalserver/postal/commit/0163ac3))\n* only accept RFC-compliant End-of-DATA sequence ([0140dc4](https://github.com/postalserver/postal/commit/0140dc4))\n* add \"postal:update\" task ([b35eea6](https://github.com/postalserver/postal/commit/b35eea6338f1888bfac2ed377d0a412680483e90))\n* add helm env var config exporter ([8938988](https://github.com/postalserver/postal/commit/893898835dcd9684ae3401549389b173a3feb1fb))\n* include list-unsubscribe-post header in dkim signatures ([e1bae60](https://github.com/postalserver/postal/commit/e1bae60768c4cf151d5a6a141985c78753dce02d)), closes [#2789](https://github.com/postalserver/postal/issues/2789) [#2788](https://github.com/postalserver/postal/issues/2788)\n* support for additional SMTP client options ([0daa667](https://github.com/postalserver/postal/commit/0daa667b55fd9b948da643d37ec438e341809369))\n\n\n### Bug Fixes\n\n* fixes potential issue if machine hostname cannot be determined ([0fcf778](https://github.com/postalserver/postal/commit/0fcf778))\n* raise an error if MX lookup times out during sending ([fadca88](https://github.com/postalserver/postal/commit/fadca88)), closes [#2833](https://github.com/postalserver/postal/issues/2833)\n* set correct component in health server log lines ([a7a9a18](https://github.com/postalserver/postal/commit/a7a9a18))\n* translate unicode domain names to Punycode for compatibility with DNS (#2823) ([be0df7b](https://github.com/postalserver/postal/commit/be0df7b))\n* remove user email verification ([e05f0b3](https://github.com/postalserver/postal/commit/e05f0b3616da8b962b763c48a2139882fd88047a))\n* unescape in URLs which are stored for tracking ([1da1182](https://github.com/postalserver/postal/commit/1da1182c23e9673d8f109d8ed29e80983cdccabf)), closes [#2568](https://github.com/postalserver/postal/issues/2568) [#907](https://github.com/postalserver/postal/issues/907) [#115](https://github.com/postalserver/postal/issues/115)\n* update wording about tracking domain cnames ([0d3eccb](https://github.com/postalserver/postal/commit/0d3eccb368630b4fd21bd858a7829ec00c35f153)), closes [#2808](https://github.com/postalserver/postal/issues/2808)\n\n\n### Documentation\n\n* add message_db.encoding to config docs ([0c1f925](https://github.com/postalserver/postal/commit/0c1f925))\n* add new repo readme welcome image ([afa1726](https://github.com/postalserver/postal/commit/afa1726))\n* add quick contributing instructions ([8d21adc](https://github.com/postalserver/postal/commit/8d21adc))\n* update SECURITY policy ([cfc1c9b](https://github.com/postalserver/postal/commit/cfc1c9b))\n* update docs for how IP address allocation works ([07eb152](https://github.com/postalserver/postal/commit/07eb152)), closes [#2209](https://github.com/postalserver/postal/issues/2209)\n\n### Miscellaneous Chores\n\n* upgrade bundler to 2.5.6 ([1ae8ef6](https://github.com/postalserver/postal/commit/1ae8ef6))\n* upgrade rails to 7.0 and other dependencies ([ecd09a2](https://github.com/postalserver/postal/commit/ecd09a2))\n* upgrade ruby to 3.2.2 and nodejs to 20.x ([72715fe](https://github.com/postalserver/postal/commit/72715fe))\n\n## [2.3.2](https://github.com/postalserver/postal/compare/2.3.1...2.3.2) (2024-03-01)\n\n\n### Bug Fixes\n\n* truncate output and details in deliveries to 250 characters ([694240d](https://github.com/postalserver/postal/commit/694240ddcdef1df9b32888de8fb743d2dee86462)), closes [#2831](https://github.com/postalserver/postal/issues/2831)\n\n## [2.3.1](https://github.com/postalserver/postal/compare/2.3.0...2.3.1) (2024-02-23)\n\n\n### Bug Fixes\n\n* update raw headers after changing messages to during parsing ([2834e2c](https://github.com/postalserver/postal/commit/2834e2c37971db9b0b0498e38b382cf1f8ee26eb)), closes [#2816](https://github.com/postalserver/postal/issues/2816)\n\n\n### Miscellaneous Chores\n\n* **github-actions:** add 'docs' label to exclude from staleness checks ([57b72fb](https://github.com/postalserver/postal/commit/57b72fb4b7f7fc934cfa23906de65b8f6d6d1978))\n* **github-actions:** add action to close stale issues and PRs ([d90a456](https://github.com/postalserver/postal/commit/d90a456dfa661d87e820160d2045c73c765564d2))\n* **github-actions:** allow stale action to be run on demand ([559b08d](https://github.com/postalserver/postal/commit/559b08ddd31ecd904fd09c1e2822161b853166b9))\n\n## [2.3.0](https://github.com/postalserver/postal/compare/2.2.2...2.3.0) (2024-02-13)\n\n\n### Features\n\n* privacy mode ([15f9671](https://github.com/postalserver/postal/commit/15f9671b667cf369255aaa27ee4257267990095c))\n* remove strip_received_headers config option ([ed2e62b](https://github.com/postalserver/postal/commit/ed2e62b94fe76d7aeca0ede98f11a1c4f94c5996))\n\n\n### Bug Fixes\n\n* add ruby platform to gemfile ([1fceef7](https://github.com/postalserver/postal/commit/1fceef7cea76352fd6166fb3f1e8d0ff8591078e))\n* explicitly disable TLS & starttls for unknown SSL modes ([42ab5b3](https://github.com/postalserver/postal/commit/42ab5b3a6b21992c89f8479137699dc9090f0ccc)), closes [#2564](https://github.com/postalserver/postal/issues/2564)\n* fix bug with received header in SMTP server ([ba5bfbd](https://github.com/postalserver/postal/commit/ba5bfbd6a0af9ea33bedb2948822417bd1a3fbc5))\n* retry mysql connections on message DB pool ([f9f7fb3](https://github.com/postalserver/postal/commit/f9f7fb30fee46b661b6dccde4362383ea591532b))\n* use correct method for disconnecting smtp connections ([7c23994](https://github.com/postalserver/postal/commit/7c23994d243ec7d9a17ee053f8c3fa8de0a33097))\n\n\n### Styles\n\n* **rubocop:** disable complexity cops for now ([930cf39](https://github.com/postalserver/postal/commit/930cf39dba37401f90e293c938dee07daf3d9a31))\n* **rubocop:** disable Style/SpecialGlobalVars ([be97f43](https://github.com/postalserver/postal/commit/be97f4330897f96085eb29ed7019b1a3e50af88e))\n* **rubocop:** disable Style/StringConcatenation cop ([d508772](https://github.com/postalserver/postal/commit/d508772a40ef26e5c3a8304aa1f2b8c7985081bd))\n* **rubocop:** Layout/* ([0e0aca0](https://github.com/postalserver/postal/commit/0e0aca06c90f6d2f4db1c4090a35c4537c76e13a))\n* **rubocop:** Layout/EmptyLineAfterMagicComment ([0e4ed5c](https://github.com/postalserver/postal/commit/0e4ed5ca0393f9a56e1efa7ae377d2e4b876bfe1))\n* **rubocop:** Layout/EmptyLines ([0cf35a8](https://github.com/postalserver/postal/commit/0cf35a83926d499a279775bcc32dd4ea79b7a8c9))\n* **rubocop:** Layout/EmptyLinesAroundBlockBody ([cfd8d63](https://github.com/postalserver/postal/commit/cfd8d63321d1821aad7fa9d6b8462c3d551aca61))\n* **rubocop:** Layout/LeadingCommentSpace ([59f299b](https://github.com/postalserver/postal/commit/59f299b704533488b74075beb8692397eb434aab))\n* **rubocop:** Layout/LineLength ([e142d0d](https://github.com/postalserver/postal/commit/e142d0da5fbee19e6f9f1741ff9dee0a2d7dd169))\n* **rubocop:** Layout/MultilineMethodCallBraceLayout ([a5d5ba5](https://github.com/postalserver/postal/commit/a5d5ba5326728413bb95456e92c854977d225a7f))\n* **rubocop:** Lint/DuplicateBranch ([a1dc0f7](https://github.com/postalserver/postal/commit/a1dc0f77ac69937d7f30c9401608dfbe66987d45))\n* **rubocop:** Lint/DuplicateMethods ([bab6346](https://github.com/postalserver/postal/commit/bab6346239e4f50bdd51101c45f3a0cd66f47096))\n* **rubocop:** Lint/IneffectiveAccessModifier ([6ad56ee](https://github.com/postalserver/postal/commit/6ad56ee9c9e5bad19b065fcec3ada3280adbb1f4))\n* **rubocop:** Lint/MissingSuper ([4674e63](https://github.com/postalserver/postal/commit/4674e63b5ff84307f5b772e870e88109af2daf52))\n* **rubocop:** Lint/RedundantStringCoercion ([12a5ef3](https://github.com/postalserver/postal/commit/12a5ef3279bf6c1e5c38bf7e846de1d17bf98c09))\n* **rubocop:** Lint/ShadowedException ([0966b44](https://github.com/postalserver/postal/commit/0966b44018bc1e2f131358635776fcc3b75ee8eb))\n* **rubocop:** Lint/ShadowingOuterLocalVariable ([7119e86](https://github.com/postalserver/postal/commit/7119e8642dffeee7a27f90145073e45664ea58d3))\n* **rubocop:** Lint/SuppressedException ([278ef08](https://github.com/postalserver/postal/commit/278ef0886ac53e6bed15793301dc69c95a37dbde))\n* **rubocop:** Lint/UnderscorePrefixedVariableName ([ec7dcf4](https://github.com/postalserver/postal/commit/ec7dcf4f9a0bdb367a90f1a3b35336909ebc60d7))\n* **rubocop:** Lint/UnusedBlockArgument ([ee94e4e](https://github.com/postalserver/postal/commit/ee94e4e1a013bbe8fbdd8ef94f15ed0fa20709ac))\n* **rubocop:** Lint/UselessAssignment ([7590a46](https://github.com/postalserver/postal/commit/7590a462341bddd412e660db9546ba1909aea9d7))\n* **rubocop:** Naming/FileName ([919a601](https://github.com/postalserver/postal/commit/919a60116c5d81ed787061ff4614da4f1e067d4e))\n* **rubocop:** Naming/MemoizedInstanceVariableName ([9563f30](https://github.com/postalserver/postal/commit/9563f30c96fba12073e845319b8d79a542d88109))\n* **rubocop:** relax method length and block nexting for now ([b0ac9ef](https://github.com/postalserver/postal/commit/b0ac9ef0b96ab78c2961f45b6e9f20f87a6f1d07))\n* **rubocop:** remaining offences ([ec63666](https://github.com/postalserver/postal/commit/ec636661d5c4b9e8f48e6f263ffef834acb68b39))\n* **rubocop:** Security/YAMLLoad ([389ea77](https://github.com/postalserver/postal/commit/389ea7705047bf8700836137514b2497af3c6c01))\n* **rubocop:** Style/AndOr ([b9f3f31](https://github.com/postalserver/postal/commit/b9f3f313f8ec992917bad3a51f0481f89675e935))\n* **rubocop:** Style/ClassAndModuleChildren ([e896f46](https://github.com/postalserver/postal/commit/e896f4689a8fc54979f0a6c2b7ce14746856bad6))\n* **rubocop:** Style/For ([04a3483](https://github.com/postalserver/postal/commit/04a34831c74a3a44547f93100c35db650bc4eef6))\n* **rubocop:** Style/FrozenStringLiteralComment ([6ab36c0](https://github.com/postalserver/postal/commit/6ab36c09c966eb9a8b8ada52155f74d2537977f2))\n* **rubocop:** Style/GlobalStdStream ([75be690](https://github.com/postalserver/postal/commit/75be6907483ea25f828461eb790d3f6f46ca683b))\n* **rubocop:** Style/GlobalVars ([157d114](https://github.com/postalserver/postal/commit/157d11457c520147807901b75b3ba22d29172f24))\n* **rubocop:** Style/HashEachMethods ([c995027](https://github.com/postalserver/postal/commit/c995027ff53962ae49341372f75e2bf43ecde0d2))\n* **rubocop:** Style/HashExcept ([83ac764](https://github.com/postalserver/postal/commit/83ac76451071f097e7197f77fc5ad16e9cf58593))\n* **rubocop:** Style/IdenticalConditionalBranches ([6a58ecf](https://github.com/postalserver/postal/commit/6a58ecf605250b8fa891cb14fca0c0e0ce0d7eb9))\n* **rubocop:** Style/MissingRespondToMissing ([ffcb707](https://github.com/postalserver/postal/commit/ffcb707247fe2a69905aa6e4dc668abeb9924611))\n* **rubocop:** Style/MultilineBlockChain ([c6326a6](https://github.com/postalserver/postal/commit/c6326a6524e6d71d23bc2c256f3f9416c38b0846))\n* **rubocop:** Style/MutableConstant ([129dffa](https://github.com/postalserver/postal/commit/129dffab9ed8726ca4066e7052adc699129de2d2))\n* **rubocop:** Style/NumericPredicate ([c558f1c](https://github.com/postalserver/postal/commit/c558f1c69ce9498564161d8cef3fcb8213103498))\n* **rubocop:** Style/PreferredHashMethods ([013b3ea](https://github.com/postalserver/postal/commit/013b3ea9315c14f24b08574d68e1688f33d78b8d))\n* **rubocop:** Style/SafeNavigation ([00a02f2](https://github.com/postalserver/postal/commit/00a02f2655b6e3296ad0e7ea9b9872da936b56ed))\n* **rubocop:** Style/SelectByRegexp ([9ce28a4](https://github.com/postalserver/postal/commit/9ce28a427fadf6fafd942e009792be4b5539d40d))\n* **rubocop:** Style/StringLiterals ([b4cc812](https://github.com/postalserver/postal/commit/b4cc81264c85d5f0061e5b5121a30d7bdcabc189))\n* **rubocop:** Style/WordArray ([bd85920](https://github.com/postalserver/postal/commit/bd8592056573d1c6933d753ed50bdf9a7466e761))\n* **rubocop:** update rubocop rules ([6d4dea7](https://github.com/postalserver/postal/commit/6d4dea7f7f0145ff2b99cf2505bc70a3e5925256))\n\n\n### Miscellaneous Chores\n\n* annotate models ([6214892](https://github.com/postalserver/postal/commit/6214892710e21c2aa29a319d5809f7bdf0d50529))\n* silence message DB migrations during provisioning ([c83601a](https://github.com/postalserver/postal/commit/c83601af69f35e338b1f7c10ef7994f74b96f8bf))\n\n\n### Code Refactoring\n\n* remove reloading on the smtp server ([c3c304e](https://github.com/postalserver/postal/commit/c3c304e98b3274433248792b6403acf63d7a513b))\n\n\n### Tests\n\n* add initial tests for Postal::SMTPServer::Client ([dece1d4](https://github.com/postalserver/postal/commit/dece1d487ac2fdce104700939a79a5579b60a0cb))\n* FactoryBot.lint will lint all registered factories ([25d7d66](https://github.com/postalserver/postal/commit/25d7d66b4709fe5442d554097a6ef074aeb15f72))\n* remove FACTORIES_EXCLUDED_FROM_LINT ([1cf665a](https://github.com/postalserver/postal/commit/1cf665a0cf61d1eae3d08bdadf6fccaab6413023))\n\n## [2.2.2](https://github.com/postalserver/postal/compare/2.2.1...2.2.2) (2024-02-06)\n\n\n### Bug Fixes\n\n* adds new connection pool which will discard failed clients ([54306a9](https://github.com/postalserver/postal/commit/54306a974802c2e4d17e0980531e2d0dba08150a)), closes [#2780](https://github.com/postalserver/postal/issues/2780)\n* re-add reconnect: true to database ([7bc5230](https://github.com/postalserver/postal/commit/7bc5230cbaae58fb6f8512d1d1b0e6a2eb989b56))\n* upgrade nokogiri ([f05c2e4](https://github.com/postalserver/postal/commit/f05c2e4503688e59a5ef513a5a1064d0ebbb5813))\n\n\n### Tests\n\n* rename database spec file ([b9edcf5](https://github.com/postalserver/postal/commit/b9edcf5b7dda7f4976a9d3f90668bbdacea57350))\n\n## [2.2.1](https://github.com/postalserver/postal/compare/2.2.0...2.2.1) (2024-02-03)\n\n\n### Bug Fixes\n\n* fixes issue starting application in production mode ([4528a14](https://github.com/postalserver/postal/commit/4528a14d273c141e5719f19c3b08c00364b47638))\n\n\n### Code Refactoring\n\n* remove Postal.database_url ([96ba4b8](https://github.com/postalserver/postal/commit/96ba4b8f309cfcd1d605e5c7fc05507b21c78c6f))\n\n## [2.2.0](https://github.com/postalserver/postal/compare/2.1.6...2.2.0) (2024-02-01)\n\n\n### Features\n\n* load signing key path from POSTAL_SIGNING_KEY_PATH ([4a46f69](https://github.com/postalserver/postal/commit/4a46f690de3010f1ae4d6c17739530a4eae35c09))\n* support for configuring postal with environment variables ([854aa5e](https://github.com/postalserver/postal/commit/854aa5ebc87de692b4691d48759aefd6fae9d133))\n\n\n### Bug Fixes\n\n* don't use indifferent access for job params ([2bad645](https://github.com/postalserver/postal/commit/2bad645d980ad4b712a3c863b5350e4ee2895071)), closes [#2477](https://github.com/postalserver/postal/issues/2477) [#2714](https://github.com/postalserver/postal/issues/2714) [#2476](https://github.com/postalserver/postal/issues/2476) [#2500](https://github.com/postalserver/postal/issues/2500)\n* extract x-postal-tag before holding ([6b2bf90](https://github.com/postalserver/postal/commit/6b2bf9062d662ede14617c4995ffaacca023a3b1)), closes [#2684](https://github.com/postalserver/postal/issues/2684)\n* fixes error messages in web ui ([71f51db](https://github.com/postalserver/postal/commit/71f51db3c2515addaf8b280667555427d64796be))\n* ignore message DB migrations in autoloader ([3fb40e4](https://github.com/postalserver/postal/commit/3fb40e4e247893b314e42affa4604a7a71a52c59))\n* move tracking middleware before host authorization ([49cceaa](https://github.com/postalserver/postal/commit/49cceaa6ca862965448041279fc439ecba163ff8)), closes [#2415](https://github.com/postalserver/postal/issues/2415)\n* use utc timestamps when determining raw table names ([ce19bf7](https://github.com/postalserver/postal/commit/ce19bf7988d522bf46aabf68090751427e286ffc))\n\n\n### Miscellaneous Chores\n\n* add binstubs for bundle and rspec ([41f6cf4](https://github.com/postalserver/postal/commit/41f6cf4d909518526af55ecb3fcccfa8fb8e1da2))\n* add script to send html emails to a local SMTP server ([8794a2f](https://github.com/postalserver/postal/commit/8794a2f44783658a075a6f3985079ae4743412b1))\n\n\n### Code Refactoring\n\n* remove explicit autoload ([0f9882f](https://github.com/postalserver/postal/commit/0f9882f13204124df630606b1b9e36787c9c4011))\n* remove Postal::Job.perform method ([990b575](https://github.com/postalserver/postal/commit/990b575902c45bb1678cc95f53ef3166c4b7092e))\n\n## [2.1.6](https://github.com/postalserver/postal/compare/2.1.5...2.1.6) (2024-01-30)\n\n\n### Miscellaneous Chores\n\n* **build:** fixes docker login action credentials ([8810856](https://github.com/postalserver/postal/commit/88108566f8ab33f1a4263a36a5c1ffc071645ac3))\n* update release please to include more categories in changelog ([e156c21](https://github.com/postalserver/postal/commit/e156c21dee304de7d10c2958c493cce73c2d8fea))\n\n## [2.1.5](https://github.com/postalserver/postal/compare/2.1.4...2.1.5) (2024-01-30)\n\n\n### Bug Fixes\n\n* duplicate string before modifying it to prevent frozen string errors ([f0a8aca](https://github.com/postalserver/postal/commit/f0a8aca6e10064fb16daefff9e22dcc20a831868))\n* fixed typo (rfc number) ([2f62baa](https://github.com/postalserver/postal/commit/2f62baa238fc1102706ee4acf079b7a876b05283))\n* fixes typo in on track domains page ([77bd77b](https://github.com/postalserver/postal/commit/77bd77b629fcbc44b8d27deb0d33a457b02309f2))\n* mail view encoding issue [#2462](https://github.com/postalserver/postal/issues/2462) ([#2596](https://github.com/postalserver/postal/issues/2596)) ([59f4478](https://github.com/postalserver/postal/commit/59f44781973489817efb5b3435d95d25f44f90ce))\n* match IPv4 mapped IPv6 addresses when searching for SMTP-IP credentials ([8b525d0](https://github.com/postalserver/postal/commit/8b525d0381a9e0113af808b9ec2eb47bf78ec60b))\n\n## 2.1.4\n\n### Bug Fixes\n\n- Move RubyVer functionality to Utilities module ([5998bf](https://github.com/postalserver/postal/commit/5998bf376a274df19f29877e7f68ea75f298c9f9))\n\n## 2.1.3\n\n### Features\n- Upgrade to Ruby 3.2.1 & Rails 6.1 ([957b78](https://github.com/postalserver/postal/commit/957b784658cda8c4c95cf1f2b65e05d99d23d427))\n- Make resent-sender header optional ([c6fb8d](https://github.com/postalserver/postal/commit/c6fb8d223bdeaccdc9e8bdbd031fe3f325ac0677))\n- Log CRAM-MD5 authentication failures ([9b1ed1](https://github.com/postalserver/postal/commit/9b1ed1e7e16a8f55a5bd7b7ce72195a08ca2968d))\n- Always use multipart/alternative parts in generated emails ([d0db13](https://github.com/postalserver/postal/commit/d0db1345a2bf8f538b01b974e74391da6fffe2b1))\n\n### Bug Fixes\n\n- Use non-blocking function to negotiate TLS connections ([a7dd19](https://github.com/postalserver/postal/commit/a7dd19baac8300f4d8ee89d0050479e08fdf9176))\n- Fix to newline conversion process ([9f4ef8](https://github.com/postalserver/postal/commit/9f4ef8f57a839c5529b4f00a36b832740386b4ed))\n- Remove custom scrollbars ([b22f1b](https://github.com/postalserver/postal/commit/b22f1bdb2e2d66b096ca993d6a5f4f708274a4a2))\n- Truncate 'output' field to avoid overflowing varchar(512) in database ([a188a1](https://github.com/postalserver/postal/commit/a188a161cbdcfd70158b09b53cef622842357c26))\n- Fix link replacement in multipart messsages ([7ea00d](https://github.com/postalserver/postal/commit/7ea00dfa3bc3c7650cc2b134beacbff22101a913))\n- Fix confusing error message when deleting IP pools ([cefc7d](https://github.com/postalserver/postal/commit/cefc7d17b82f610001859a8e323ee1dfde149ba5))\n- Connect to correct IP rather than hostname suring SMTP delivery ([159509](https://github.com/postalserver/postal/commit/159509a3ed29ae33cba522b255904992922dcfdf))\n- Change retry timings to avoid re-sending messages too early ([c8d27b](https://github.com/postalserver/postal/commit/c8d27b2963af122d6555abdf0742d2d2d6f11ce5))\n\n## 2.1.2\n\n### Features\n\n- support for AMQPS for rabbitmq connections ([9f0697](https://github.com/postalserver/postal/commit/9f0697f194209f5fae5e451ba8fb888413fe37fa))\n\n### Bug Fixes\n\n- retry connections without SSL when SSL issue is encountered during smtp sending ([0dc682](https://github.com/postalserver/postal/commit/0dc6824a8f0315ea42b08f7e6812b821b62489c9))\n\n## 2.1.1\n\n### Features\n\n- allow @ and % in webhook urls ([c60c69](https://github.com/postalserver/postal/commit/c60c69db1800775776da4c28c68001f230fe5888))\n\n### Bug Fixes\n\n- fixes broken styling on errors ([a0c87e](https://github.com/postalserver/postal/commit/a0c87e7bf16a19f06c13797e3329a4fed91370a1))\n- use the Postal logger system for the rails log ([5b04fa](https://github.com/postalserver/postal/commit/5b04faca39c69757bd7d695b82984f8b4a41cac3))\n\n## 2.1.0\n\n### Features\n\n- support for configuring the default spam threshold values for new servers ([724325](https://github.com/postalserver/postal/commit/724325a1b97d61ef1e134240e4f70aaad39dbf98))\n- support for using rspamd for spam filtering ([a1277b](https://github.com/postalserver/postal/commit/a1277baba56ea6d6b4da4bba87b00cd3dbf0305e))\n\n### Bug Fixes\n\n- **dkim:** fixes timing race condition when signing emails ([232b60](https://github.com/postalserver/postal/commit/232b605f5bb8ab61156e1fb9860705fed017ed41))\n- **docker:** fixes issue caused by changes to underlying ruby:2.6 image ([6570ff](https://github.com/postalserver/postal/commit/6570ff1f7797ff9a307dd96ed4ff37be14bf79ab))\n\n## 2.0.0\n\n### Features\n\n- **ui:** add footer with links to docs and discussions ([1247da](https://github.com/postalserver/postal/commit/1247dae2e060a695a13a30ba072ca5e6dea45202))\n\n### Bug Fixes\n\n- **dkim:** ensure DKIM-Signature headers are appropriately wrapped ([476129](https://github.com/postalserver/postal/commit/476129cc1ba44e9014768d5ba7193587f78cb5d5))\n- **docs:** update port numbers to specify the actual port number the SMTP server is listening on ([4404b3](https://github.com/postalserver/postal/commit/4404b3e02c1722808157c3f590310ead9e28641d))\n- **logging:** fix spelling of graylog ([2a11e0](https://github.com/postalserver/postal/commit/2a11e0c0a5b7c7f630af28cf4af5511d9bce6dda))\n\n## 2.0.0-beta.1\n\n### Features\n\n- **config:** support for loading a postal.local.yml config file from the config root if it exists ([8e3294](https://github.com/postalhq/postal/commit/8e3294ba1af4b797d36bd1ca9226190ed80f65cc))\n- **smtp_server:** allow bind address to be configured ([4a410c](https://github.com/postalhq/postal/commit/4a410c8c9f6fa1ef993a68c37afeaf31230585f7))\n- add priorities to IP address assignment ([21a8d8](https://github.com/postalhq/postal/commit/21a8d890459958375d4a49a5b7f31f4900a9e8b1))\n\n### Bug Fixes\n\n- **dkim:** fixes bug with signing dkim bodies ([189dfa](https://github.com/postalhq/postal/commit/189dfa509b4750f1e4cc6f43f6565edd3a35139c))\n- **smtp_server:** attempt to redact plain-text passwords from log output ([fcb636](https://github.com/postalhq/postal/commit/fcb63616e1ce578d7d4fd1c96ddc4ee0f7a71534))\n- **smtp_server:** fixes issue with malformed rcpt to ([e0ba05](https://github.com/postalhq/postal/commit/e0ba05acb11108d98a460ae3fac653ceefb5f672))\n- **smtp_server:** refactor mx lookups to randomly order mx records with the same priority ([bc2239](https://github.com/postalhq/postal/commit/bc22394fdd4f26dddd576840b49d7c25802cda7d))\n- **smtp_server:** updated line split logic, normalize all linebreaks to \\r\\n ([e8ba9e](https://github.com/postalhq/postal/commit/e8ba9ee4276e81af84ecb6ff6f0c024ef99f6ddc))\n- add resolv 0.2.1 ([eef1a3](https://github.com/postalhq/postal/commit/eef1a365a28e133750c4d5a4ac0eeeed223e303d))\n- always obey POSTAL_CONFIG_ROOT ([1d22ca](https://github.com/postalhq/postal/commit/1d22ca0f85b58b04aedde9071d9fc5ecd44af4de))\n- fix issue with determining if an SMTP connection is encrypted or not ([73870d](https://github.com/postalhq/postal/commit/73870d6a92400fc8ec1493016817dfac074ffd06))\n- remove a few leftover fast server artifacts ([5cd06e](https://github.com/postalhq/postal/commit/5cd06e126b6caac502245754b360194365152415))\n- replace Fixnum with Integer ([52a23f](https://github.com/postalhq/postal/commit/52a23fa86f94c14dfc7edccbf414dda34c46bc12))\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Postal\n\nThis doc explains how to go about running Postal in development to allow you to make contributions to the project.\n\n## Dependencies\n\nYou will need a MySQL database server to get started. Postal needs to be able to make databases within that server whenever new mail servers are created so the permissions that you use should be suitable for that.\n\nYou'll also need Ruby. Postal currently uses Ruby 3.2.2. Install that using whichever version manager takes your fancy - rbenv, asdf, rvm etc.\n\n## Clone\n\nYou'll need to clone the repository\n\n```\ngit clone git@github.com:postalserver/postal\n```\n\nOnce cloned, you can install the Ruby dependencies using bundler.\n\n```\nbundle install\n```\n\n## Configuration\n\nConfiguration is handled using a config file. This lives in `config/postal/postal.yml`. An example configuration file is provided in `config/examples/development.yml`. This example is for development use only and not an example for production use.\n\nYou'll also need a key for signing. You can generate one of these like this:\n\n```\nopenssl genrsa -out config/postal/signing.key 2048\n```\n\nIf you're running the tests (and you probably should be), you'll find an example file for test configuration in `config/examples/test.yml`. This should be placed in `config/postal/postal.test.yml` with the appropriate values.\n\nIf you prefer, you can configure Postal using environment variables. These should be placed in `.env` or `.env.test` as apprpriate.\n\n## Running\n\nThe neatest way to run postal is to ensure that `./bin` is your `$PATH` and then use one of the following commands.\n\n* `bin/dev` - will run all components of the application using Foreman\n* `bin/postal` - will run the Postal binary providing access to running individual components or other tools.\n\n## Database initialization\n\nUse the commands below to initialize your database and make your first user.\n\n```\npostal initialize\npostal make-user\n```\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM ruby:3.4.6-slim-bookworm AS base\n\nSHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]\nRUN apt-get update \\\n  && apt-get install --no-install-recommends -y curl \\\n  && apt-get clean \\\n  && rm -rf /var/lib/apt/lists/*\n\nRUN (curl -sL https://deb.nodesource.com/setup_20.x | bash -)\n\n# Install main dependencies\nRUN apt-get update && \\\n  apt-get install -y --no-install-recommends \\\n    build-essential  \\\n    netcat-openbsd \\\n    libmariadb-dev \\\n    libcap2-bin \\\n    nano \\\n    libyaml-dev \\\n    nodejs \\\n  && apt-get clean \\\n  && rm -rf /var/lib/apt/lists/*\n\nRUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/ruby\n\n# Configure 'postal' to work everywhere (when the binary exists\n# later in this process)\nENV PATH=\"/opt/postal/app/bin:${PATH}\"\n\n# Setup an application\nRUN useradd -r -d /opt/postal -m -s /bin/bash -u 999 postal\nUSER postal\nRUN mkdir -p /opt/postal/app /opt/postal/config\nWORKDIR /opt/postal/app\n\n# Install bundler\nRUN gem install bundler -v 2.7.2 --no-doc\n\n# Install the latest and active gem dependencies and re-run\n# the appropriate commands to handle installs.\nCOPY --chown=postal Gemfile Gemfile.lock ./\nRUN bundle install\n\n# Copy the application (and set permissions)\nCOPY ./docker/wait-for.sh /docker-entrypoint.sh\nCOPY --chown=postal . .\n\n# Export the version\nARG VERSION\nARG BRANCH\nRUN if [ \"$VERSION\" != \"\" ]; then echo $VERSION > VERSION; fi \\\n  && if [ \"$BRANCH\" != \"\" ]; then echo $BRANCH > BRANCH; fi\n\n# Set paths for when running in a container\nENV POSTAL_CONFIG_FILE_PATH=/config/postal.yml\n\n# Set the CMD\nENTRYPOINT [ \"/docker-entrypoint.sh\" ]\nCMD [\"postal\"]\n\n# ci target - use --target=ci to skip asset compilation\nFROM base AS ci\n\n# full target - default if no --target option is given\nFROM base AS full\n\nRUN RAILS_GROUPS=assets bundle exec rake assets:precompile\nRUN touch /opt/postal/app/public/assets/.prebuilt\n"
  },
  {
    "path": "Gemfile",
    "content": "# frozen_string_literal: true\n\nsource \"https://rubygems.org\"\ngem \"abbrev\"\ngem \"authie\"\ngem \"autoprefixer-rails\"\ngem \"bcrypt\"\ngem \"chronic\"\ngem \"domain_name\"\ngem \"dotenv\"\ngem \"dynamic_form\"\ngem \"execjs\", \"~> 2.7\", \"< 2.8\"\ngem \"gelf\"\ngem \"haml\"\ngem \"hashie\"\ngem \"highline\", require: false\ngem \"jwt\"\ngem \"kaminari\"\ngem \"klogger-logger\"\ngem \"konfig-config\", \"~> 3.0\"\ngem \"logger\"\ngem \"mail\"\ngem \"mutex_m\"\ngem \"mysql2\"\ngem \"nifty-utils\"\ngem \"nilify_blanks\"\ngem \"nio4r\"\ngem \"ostruct\"\ngem \"prometheus-client\"\ngem \"puma\"\ngem \"rackup\"\ngem \"rails\", \"= 7.1.5.2\"\ngem \"resolv\"\ngem \"secure_headers\"\ngem \"sentry-rails\"\ngem \"turbolinks\", \"~> 5\"\ngem \"webrick\"\n\ngroup :oidc do\n  # These are gems which are needed for OpenID connect. They are only required by the application\n  # when OIDC is enabled in the Postal configuration.\n  gem \"omniauth_openid_connect\"\n  gem \"omniauth-rails_csrf_protection\"\nend\n\ngroup :development, :assets do\n  gem \"coffee-rails\", \"~> 5.0\"\n  gem \"jquery-rails\"\n  gem \"sass-rails\"\n  gem \"uglifier\", \">= 1.3.0\"\nend\n\ngroup :development do\n  gem \"annotate\"\n  gem \"rubocop\"\n  gem \"rubocop-rails\"\nend\n\ngroup :test do\n  gem \"database_cleaner-active_record\"\n  gem \"factory_bot_rails\"\n  gem \"rspec\"\n  gem \"rspec-rails\"\n  gem \"shoulda-matchers\"\n  gem \"timecop\"\n  gem \"webmock\"\nend\n"
  },
  {
    "path": "MIT-LICENCE",
    "content": "Copyright 2017-2024 Krystal Hosting Ltd\nCopyright 2024 Adam Cooke\nCopyright 2024 Charlie Smurthwaite\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "Procfile.dev",
    "content": "web: unset PORT; bundle exec puma -C config/puma.rb\nworker: bundle exec ruby script/worker.rb\nsmtp: unset PORT; bundle exec ruby script/smtp_server.rb\n"
  },
  {
    "path": "README.md",
    "content": "![GitHub Header](https://github.com/postalserver/.github/assets/4765/7a63c35d-2f47-412f-a6b3-aebc92a55310)\n\n**Postal** is a complete and fully featured mail server for use by websites & web servers. Think Sendgrid, Mailgun or Postmark but open source and ready for you to run on your own servers. \n\n* [Documentation](https://docs.postalserver.io)\n* [Installation Instructions](https://docs.postalserver.io/getting-started)\n* [FAQs](https://docs.postalserver.io/welcome/faqs) & [Features](https://docs.postalserver.io/welcome/feature-list)\n* [Discussions](https://github.com/postalserver/postal/discussions) - ask for help or request a feature\n* [Join us on Discord](https://discord.postalserver.io)\n"
  },
  {
    "path": "Rakefile",
    "content": "# frozen_string_literal: true\n\n# Add your own tasks in files placed in lib/tasks ending in .rake,\n# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.\n\nrequire_relative \"config/application\"\n\nRails.application.load_tasks\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nWe only support updates to the 3.x versions of Postal.\n\n| Version | Supported          |\n| ------- | ------------------ |\n| 3.x.x   | :white_check_mark: |\n| < 3.0   | :x:                |\n\n## Reporting a Vulnerability\n\nIf you discover a vulnerability in Postal, please do not post an issue on GitHub. Instead you should send an\ne-mail to security@postalserver.io with details. We will get back to you directly.\n"
  },
  {
    "path": "app/assets/config/manifest.js",
    "content": "//= link_tree ../images\n//= link_directory ../javascripts .js\n//= link_directory ../stylesheets .css\n//= link application/application.css\n//= link application/application.js\n"
  },
  {
    "path": "app/assets/images/.keep",
    "content": ""
  },
  {
    "path": "app/assets/javascripts/application/application.coffee",
    "content": "#= require jquery\n#= require jquery_ujs\n#= require turbolinks\n#= require_tree ./vendor/.\n#= require_self\n#= require_tree .\n\n$ ->\n\n  isFirefox = -> !!navigator.userAgent.match(/firefox/i)\n\n  $('html').addClass('browser-firefox') if isFirefox()\n\n  $(document).on 'turbolinks:load', ->\n    $('.js-multibox').multibox({inputCount: 6, classNames: {container: \"multibox\", input: 'input input--text multibox__input'}})\n\n  $(document).on 'keyup', (event)->\n    return if $(event.target).is('input, select, textarea')\n    if event.keyCode == 83\n      $('.js-focus-on-s').focus()\n      event.preventDefault()\n    if event.keyCode == 70\n      $('.js-focus-on-f').focus()\n      event.preventDefault()\n\n  $(document).on 'click', 'html.main .flashMessage', ->\n    $(this).hide 'fast', ->\n      $(this).remove()\n\n  $(document).on 'click', '.js-toggle-helpbox', ->\n    helpBox = $('.js-helpbox')\n    if helpBox.hasClass('is-hidden')\n      helpBox.removeClass('is-hidden')\n    else\n      helpBox.addClass('is-hidden')\n    return false\n\n  $(document).on 'input', 'input[type=range]', ->\n    value = $(this).val()\n    updateAttr = $(this).attr('data-update')\n    if updateAttr && updateAttr.length\n      $(\".\" + $(this).attr('data-update')).text(parseFloat(value, 10).toFixed(1))\n\n  $(document).on 'change', '.js-checkbox-list-toggle', ->\n    $this = $(this)\n    value = $this.val()\n    $list = $this.parent().find('.checkboxList')\n    if value == 'false' then $list.show() else $list.hide()\n\n  $(document).on 'click', '.js-toggle', ->\n    $link = $(this)\n    element = $link.attr('data-element')\n    $(element, $link.parent()).toggle()\n    false\n\n  toggleCredentialInputs = (type)->\n    $('[data-credential-key-type]').hide()\n    $('[data-credential-key-type] input').attr('disabled', true)\n    if type == 'SMTP-IP'\n      $('[data-credential-key-type=smtp-ip]').show()\n      $('[data-credential-key-type=smtp-ip] input').attr('disabled', false)\n    else\n      $('[data-credential-key-type=all]').show()\n\n  $(document).on 'change', 'select#credential_type', ->\n    value = $(this).val()\n    toggleCredentialInputs(value)\n\n  $(document).on 'turbolinks:load', ->\n    credentialTypeInput = $('select#credential_type')\n    if credentialTypeInput.length\n      toggleCredentialInputs(credentialTypeInput.val())\n"
  },
  {
    "path": "app/assets/javascripts/application/elements/ajax.coffee",
    "content": "onStart = (event) ->\n  $('.flashMessage').remove()\n  $('input, select, textarea').blur()\n  $target = $(event.target)\n  if $target.is('form')\n    $('.js-form-submit', $target).addClass('is-spinning')\n  if $target.hasClass('button')\n    $($target).addClass('is-spinning')\n\nonComplete = (event, xhr)->\n  $target = $(event.target)\n  if xhr.responseJSON\n    data = xhr.responseJSON\n    if data.redirect_to\n      Turbolinks.clearCache()\n      Turbolinks.visit(data.redirect_to, {\"action\":\"replace\"})\n      console.log \"Redirected to #{data.redirect_to}\"\n\n    if data.alert\n      unSpin($target)\n      alert(data.alert)\n\n    if data.form_errors\n      if $target.is('form')\n        unSpin($target)\n        handleErrors($target, data.form_errors)\n\n    if data.flash\n      unSpin($target)\n      $('body .flashMessage').remove()\n      for key, value of data.flash\n        $message = $(\"<div class='flashMessage flashMessage--#{key}'>#{value}</div>\")\n        $('body').prepend($message)\n\n    if data.region_html\n      unSpin($target)\n      $('.js-ajax-region').replaceWith(data.region_html)\n      $('[autofocus]', '.js-ajax-region').focus()\n\n  else\n    console.log \"Unsupported return.\"\n\nunSpin = ($target)->\n  $target.removeClass('is-spinning')\n  $('.js-form-submit', $target).removeClass('is-spinning')\n\n\nhandleErrors = (form, errors)->\n  html = $(\"<div class='formErrors errorExplanation'><ul></ul</div>\")\n  list = $('ul', html)\n  $.each errors, ->\n    list.append(\"<li>#{this}</li>\")\n  $('.formErrors', form).remove()\n  form.prepend($(html))\n  console.log errors\n\n$ ->\n  $.ajaxSettings.dataType = 'json'\n  $(document)\n    .on 'ajax:before', onStart\n    .on 'ajax:complete', onComplete\n"
  },
  {
    "path": "app/assets/javascripts/application/elements/mail_graph.coffee",
    "content": "$(document).on 'turbolinks:load', ->\n\n  mailGraph = $('.mailGraph')\n\n  if mailGraph.length\n    data = JSON.parse(mailGraph.attr('data-data'))\n    incomingMail = []\n    outgoingMail = []\n    for d in data\n      incomingMail.push(d.incoming)\n      outgoingMail.push(d.outgoing)\n\n    data =\n      series: [outgoingMail, incomingMail]\n    options =\n      fullWidth: true\n      axisY:\n        offset:40\n      axisX:\n        showGrid: false\n        offset: 0\n        showLabel: true\n      height: '230px'\n      showArea: true\n      high: if incomingMail? && incomingMail.length then undefined else 1000\n      chartPadding:\n        top:0\n        right:0\n        bottom:0\n        left:0\n\n    new Chartist.Line '.mailGraph__graph', data, options\n"
  },
  {
    "path": "app/assets/javascripts/application/elements/remembering.coffee",
    "content": "$ ->\n  $(document).on 'click', '.js-remember a', ->\n    $parent = $(this).parents('.js-remember')\n    value = $(this).attr('data-remember')\n    $parent.remove()\n    if value == 'yes'\n      $.post('/persist')\n    false\n"
  },
  {
    "path": "app/assets/javascripts/application/elements/searchable.coffee",
    "content": "ENTER = 13\nDOWN_ARROW = 40\nUP_ARROW = 38\n\nfilterList = ($container, query) ->\n  $items = getItems($container)\n  index = $container.data('searchifyIndex')\n  re = new RegExp(query, 'g')\n  $matches = $items.filter (i, item) ->\n    value = $(item).data('value')\n    re.test(value)\n  $items.addClass('is-hidden').filter($matches).removeClass('is-hidden')\n  toggleState($container, $matches.length > 0)\n  if index?\n    index = 0\n    $container.data('searchifyIndex', index)\n  highlightItem($container, $matches, index)\n\ngetContainer = ($el) ->\n  $el.closest('.js-searchable')\n\ngetEmpty = ($container) ->\n  $('.js-searchable__empty', $container)\n\ngetList = ($container) ->\n  $('.js-searchable__list', $container)\n\ngetItems = ($container) ->\n  $('.js-searchable__item', $container)\n\nhighlightItem = ($container, $scope, index) ->\n  $items = getItems($container)\n  $items.removeClass('is-highlighted')\n  $scope.eq(index).addClass('is-highlighted') if index? && $scope.length\n\nhighlightNext = ($container) ->\n  $matches = getMatches($container)\n  index = $container.data('searchifyIndex')\n  return unless $matches.length\n  if index?\n    return if index == $matches.length - 1\n    newIndex = index + 1\n  else\n    newIndex = 0\n  $container.data('searchifyIndex', newIndex)\n  highlightItem($container, $matches, newIndex)\n\nhighlightPrev = ($container) ->\n  $matches = getMatches($container)\n  index = $container.data('searchifyIndex')\n  return unless $matches.length\n  if index?\n    return if index == 0\n    newIndex = index - 1\n  else\n    newIndex = 0\n  $container.data('searchifyIndex', newIndex)\n  highlightItem($container, $matches, newIndex)\n\ngetMatches = ($container) ->\n  $items = getItems($container)\n  $items.filter(':not(.is-hidden)')\n\nsearchify = (str) ->\n  str.toLowerCase().replace(/\\W/g, '')\n\nselectHighlighted = ($container) ->\n  index = $container.data('searchifyIndex')\n  $matches = getMatches($container)\n  return unless index? && $matches.length\n  url = $matches.eq(index).data('url')\n  Turbolinks.visit(url)\n\nshowAll = ($container) ->\n  $items = getItems($container)\n  index = $container.data('searchifyIndex')\n  $items.removeClass('is-hidden')\n  toggleState($container, true)\n  if index?\n    index = 0\n    $container.data('searchifyIndex', index)\n    highlightItem($container, $items, index)\n\ntoggleState = ($container, predicate) ->\n  $empty = getEmpty($container)\n  $list = getList($container)\n  $empty.toggleClass('is-hidden', predicate)\n  $list.toggleClass('is-hidden', !predicate)\n\n# Event Handlers\n\nhandleInput = (event) ->\n  $input = $(event.target)\n  $container = getContainer($input)\n  query = searchify($input.val())\n  if query.length then filterList($container, query) else showAll($container)\n\nhandleKeydown = (event) ->\n  $container = getContainer($(event.target))\n  keyCode = event.keyCode\n  if keyCode == DOWN_ARROW\n    event.preventDefault()\n    highlightNext($container)\n  else if keyCode == ENTER\n    event.preventDefault()\n    selectHighlighted($container)\n  else if keyCode == UP_ARROW\n    event.preventDefault()\n    highlightPrev($container)\n$ ->\n  $(document)\n    .on('input', '.js-searchable__input', handleInput)\n    .on('keydown', '.js-searchable__input', handleKeydown)\n"
  },
  {
    "path": "app/assets/javascripts/application/vendor/chartist.js",
    "content": "(function (root, factory) {\n  if (typeof define === 'function' && define.amd) {\n    // AMD. Register as an anonymous module unless amdModuleId is set\n    define([], function () {\n      return (root['Chartist'] = factory());\n    });\n  } else if (typeof exports === 'object') {\n    // Node. Does not work with strict CommonJS, but\n    // only CommonJS-like environments that support module.exports,\n    // like Node.\n    module.exports = factory();\n  } else {\n    root['Chartist'] = factory();\n  }\n}(this, function () {\n\n/* Chartist.js 0.9.8\n * Copyright © 2016 Gion Kunz\n * Free to use under either the WTFPL license or the MIT license.\n * https://raw.githubusercontent.com/gionkunz/chartist-js/master/LICENSE-WTFPL\n * https://raw.githubusercontent.com/gionkunz/chartist-js/master/LICENSE-MIT\n */\n/**\n * The core module of Chartist that is mainly providing static functions and higher level functions for chart modules.\n *\n * @module Chartist.Core\n */\nvar Chartist = {\n  version: '0.9.8'\n};\n\n(function (window, document, Chartist) {\n  'use strict';\n\n  /**\n   * This object contains all namespaces used within Chartist.\n   *\n   * @memberof Chartist.Core\n   * @type {{svg: string, xmlns: string, xhtml: string, xlink: string, ct: string}}\n   */\n  Chartist.namespaces = {\n    svg: 'http://www.w3.org/2000/svg',\n    xmlns: 'http://www.w3.org/2000/xmlns/',\n    xhtml: 'http://www.w3.org/1999/xhtml',\n    xlink: 'http://www.w3.org/1999/xlink',\n    ct: 'http://gionkunz.github.com/chartist-js/ct'\n  };\n\n  /**\n   * Helps to simplify functional style code\n   *\n   * @memberof Chartist.Core\n   * @param {*} n This exact value will be returned by the noop function\n   * @return {*} The same value that was provided to the n parameter\n   */\n  Chartist.noop = function (n) {\n    return n;\n  };\n\n  /**\n   * Generates a-z from a number 0 to 26\n   *\n   * @memberof Chartist.Core\n   * @param {Number} n A number from 0 to 26 that will result in a letter a-z\n   * @return {String} A character from a-z based on the input number n\n   */\n  Chartist.alphaNumerate = function (n) {\n    // Limit to a-z\n    return String.fromCharCode(97 + n % 26);\n  };\n\n  /**\n   * Simple recursive object extend\n   *\n   * @memberof Chartist.Core\n   * @param {Object} target Target object where the source will be merged into\n   * @param {Object...} sources This object (objects) will be merged into target and then target is returned\n   * @return {Object} An object that has the same reference as target but is extended and merged with the properties of source\n   */\n  Chartist.extend = function (target) {\n    target = target || {};\n\n    var sources = Array.prototype.slice.call(arguments, 1);\n    sources.forEach(function(source) {\n      for (var prop in source) {\n        if (typeof source[prop] === 'object' && source[prop] !== null && !(source[prop] instanceof Array)) {\n          target[prop] = Chartist.extend({}, target[prop], source[prop]);\n        } else {\n          target[prop] = source[prop];\n        }\n      }\n    });\n\n    return target;\n  };\n\n  /**\n   * Replaces all occurrences of subStr in str with newSubStr and returns a new string.\n   *\n   * @memberof Chartist.Core\n   * @param {String} str\n   * @param {String} subStr\n   * @param {String} newSubStr\n   * @return {String}\n   */\n  Chartist.replaceAll = function(str, subStr, newSubStr) {\n    return str.replace(new RegExp(subStr, 'g'), newSubStr);\n  };\n\n  /**\n   * Converts a number to a string with a unit. If a string is passed then this will be returned unmodified.\n   *\n   * @memberof Chartist.Core\n   * @param {Number} value\n   * @param {String} unit\n   * @return {String} Returns the passed number value with unit.\n   */\n  Chartist.ensureUnit = function(value, unit) {\n    if(typeof value === 'number') {\n      value = value + unit;\n    }\n\n    return value;\n  };\n\n  /**\n   * Converts a number or string to a quantity object.\n   *\n   * @memberof Chartist.Core\n   * @param {String|Number} input\n   * @return {Object} Returns an object containing the value as number and the unit as string.\n   */\n  Chartist.quantity = function(input) {\n    if (typeof input === 'string') {\n      var match = (/^(\\d+)\\s*(.*)$/g).exec(input);\n      return {\n        value : +match[1],\n        unit: match[2] || undefined\n      };\n    }\n    return { value: input };\n  };\n\n  /**\n   * This is a wrapper around document.querySelector that will return the query if it's already of type Node\n   *\n   * @memberof Chartist.Core\n   * @param {String|Node} query The query to use for selecting a Node or a DOM node that will be returned directly\n   * @return {Node}\n   */\n  Chartist.querySelector = function(query) {\n    return query instanceof Node ? query : document.querySelector(query);\n  };\n\n  /**\n   * Functional style helper to produce array with given length initialized with undefined values\n   *\n   * @memberof Chartist.Core\n   * @param length\n   * @return {Array}\n   */\n  Chartist.times = function(length) {\n    return Array.apply(null, new Array(length));\n  };\n\n  /**\n   * Sum helper to be used in reduce functions\n   *\n   * @memberof Chartist.Core\n   * @param previous\n   * @param current\n   * @return {*}\n   */\n  Chartist.sum = function(previous, current) {\n    return previous + (current ? current : 0);\n  };\n\n  /**\n   * Multiply helper to be used in `Array.map` for multiplying each value of an array with a factor.\n   *\n   * @memberof Chartist.Core\n   * @param {Number} factor\n   * @returns {Function} Function that can be used in `Array.map` to multiply each value in an array\n   */\n  Chartist.mapMultiply = function(factor) {\n    return function(num) {\n      return num * factor;\n    };\n  };\n\n  /**\n   * Add helper to be used in `Array.map` for adding a addend to each value of an array.\n   *\n   * @memberof Chartist.Core\n   * @param {Number} addend\n   * @returns {Function} Function that can be used in `Array.map` to add a addend to each value in an array\n   */\n  Chartist.mapAdd = function(addend) {\n    return function(num) {\n      return num + addend;\n    };\n  };\n\n  /**\n   * Map for multi dimensional arrays where their nested arrays will be mapped in serial. The output array will have the length of the largest nested array. The callback function is called with variable arguments where each argument is the nested array value (or undefined if there are no more values).\n   *\n   * @memberof Chartist.Core\n   * @param arr\n   * @param cb\n   * @return {Array}\n   */\n  Chartist.serialMap = function(arr, cb) {\n    var result = [],\n        length = Math.max.apply(null, arr.map(function(e) {\n          return e.length;\n        }));\n\n    Chartist.times(length).forEach(function(e, index) {\n      var args = arr.map(function(e) {\n        return e[index];\n      });\n\n      result[index] = cb.apply(null, args);\n    });\n\n    return result;\n  };\n\n  /**\n   * This helper function can be used to round values with certain precision level after decimal. This is used to prevent rounding errors near float point precision limit.\n   *\n   * @memberof Chartist.Core\n   * @param {Number} value The value that should be rounded with precision\n   * @param {Number} [digits] The number of digits after decimal used to do the rounding\n   * @returns {number} Rounded value\n   */\n  Chartist.roundWithPrecision = function(value, digits) {\n    var precision = Math.pow(10, digits || Chartist.precision);\n    return Math.round(value * precision) / precision;\n  };\n\n  /**\n   * Precision level used internally in Chartist for rounding. If you require more decimal places you can increase this number.\n   *\n   * @memberof Chartist.Core\n   * @type {number}\n   */\n  Chartist.precision = 8;\n\n  /**\n   * A map with characters to escape for strings to be safely used as attribute values.\n   *\n   * @memberof Chartist.Core\n   * @type {Object}\n   */\n  Chartist.escapingMap = {\n    '&': '&amp;',\n    '<': '&lt;',\n    '>': '&gt;',\n    '\"': '&quot;',\n    '\\'': '&#039;'\n  };\n\n  /**\n   * This function serializes arbitrary data to a string. In case of data that can't be easily converted to a string, this function will create a wrapper object and serialize the data using JSON.stringify. The outcoming string will always be escaped using Chartist.escapingMap.\n   * If called with null or undefined the function will return immediately with null or undefined.\n   *\n   * @memberof Chartist.Core\n   * @param {Number|String|Object} data\n   * @return {String}\n   */\n  Chartist.serialize = function(data) {\n    if(data === null || data === undefined) {\n      return data;\n    } else if(typeof data === 'number') {\n      data = ''+data;\n    } else if(typeof data === 'object') {\n      data = JSON.stringify({data: data});\n    }\n\n    return Object.keys(Chartist.escapingMap).reduce(function(result, key) {\n      return Chartist.replaceAll(result, key, Chartist.escapingMap[key]);\n    }, data);\n  };\n\n  /**\n   * This function de-serializes a string previously serialized with Chartist.serialize. The string will always be unescaped using Chartist.escapingMap before it's returned. Based on the input value the return type can be Number, String or Object. JSON.parse is used with try / catch to see if the unescaped string can be parsed into an Object and this Object will be returned on success.\n   *\n   * @memberof Chartist.Core\n   * @param {String} data\n   * @return {String|Number|Object}\n   */\n  Chartist.deserialize = function(data) {\n    if(typeof data !== 'string') {\n      return data;\n    }\n\n    data = Object.keys(Chartist.escapingMap).reduce(function(result, key) {\n      return Chartist.replaceAll(result, Chartist.escapingMap[key], key);\n    }, data);\n\n    try {\n      data = JSON.parse(data);\n      data = data.data !== undefined ? data.data : data;\n    } catch(e) {}\n\n    return data;\n  };\n\n  /**\n   * Create or reinitialize the SVG element for the chart\n   *\n   * @memberof Chartist.Core\n   * @param {Node} container The containing DOM Node object that will be used to plant the SVG element\n   * @param {String} width Set the width of the SVG element. Default is 100%\n   * @param {String} height Set the height of the SVG element. Default is 100%\n   * @param {String} className Specify a class to be added to the SVG element\n   * @return {Object} The created/reinitialized SVG element\n   */\n  Chartist.createSvg = function (container, width, height, className) {\n    var svg;\n\n    width = width || '100%';\n    height = height || '100%';\n\n    // Check if there is a previous SVG element in the container that contains the Chartist XML namespace and remove it\n    // Since the DOM API does not support namespaces we need to manually search the returned list http://www.w3.org/TR/selectors-api/\n    Array.prototype.slice.call(container.querySelectorAll('svg')).filter(function filterChartistSvgObjects(svg) {\n      return svg.getAttributeNS(Chartist.namespaces.xmlns, 'ct');\n    }).forEach(function removePreviousElement(svg) {\n      container.removeChild(svg);\n    });\n\n    // Create svg object with width and height or use 100% as default\n    svg = new Chartist.Svg('svg').attr({\n      width: width,\n      height: height\n    }).addClass(className).attr({\n      style: 'width: ' + width + '; height: ' + height + ';'\n    });\n\n    // Add the DOM node to our container\n    container.appendChild(svg._node);\n\n    return svg;\n  };\n\n  /**\n   * Ensures that the data object passed as second argument to the charts is present and correctly initialized.\n   *\n   * @param  {Object} data The data object that is passed as second argument to the charts\n   * @return {Object} The normalized data object\n   */\n  Chartist.normalizeData = function(data) {\n    // Ensure data is present otherwise enforce\n    data = data || {series: [], labels: []};\n    data.series = data.series || [];\n    data.labels = data.labels || [];\n\n    // Check if we should generate some labels based on existing series data\n    if (data.series.length > 0 && data.labels.length === 0) {\n      var normalized = Chartist.getDataArray(data),\n          labelCount;\n\n      // If all elements of the normalized data array are arrays we're dealing with\n      // data from Bar or Line charts and we need to find the largest series if they are un-even\n      if (normalized.every(function(value) {\n        return value instanceof Array;\n      })) {\n        // Getting the series with the the most elements\n        labelCount = Math.max.apply(null, normalized.map(function(series) {\n          return series.length;\n        }));\n      } else {\n        // We're dealing with Pie data so we just take the normalized array length\n        labelCount = normalized.length;\n      }\n\n      // Setting labels to an array with emptry strings using our labelCount estimated above\n      data.labels = Chartist.times(labelCount).map(function() {\n        return '';\n      });\n    }\n    return data;\n  };\n\n  /**\n   * Reverses the series, labels and series data arrays.\n   *\n   * @memberof Chartist.Core\n   * @param data\n   */\n  Chartist.reverseData = function(data) {\n    data.labels.reverse();\n    data.series.reverse();\n    for (var i = 0; i < data.series.length; i++) {\n      if(typeof(data.series[i]) === 'object' && data.series[i].data !== undefined) {\n        data.series[i].data.reverse();\n      } else if(data.series[i] instanceof Array) {\n        data.series[i].reverse();\n      }\n    }\n  };\n\n  /**\n   * Convert data series into plain array\n   *\n   * @memberof Chartist.Core\n   * @param {Object} data The series object that contains the data to be visualized in the chart\n   * @param {Boolean} reverse If true the whole data is reversed by the getDataArray call. This will modify the data object passed as first parameter. The labels as well as the series order is reversed. The whole series data arrays are reversed too.\n   * @param {Boolean} multi Create a multi dimensional array from a series data array where a value object with `x` and `y` values will be created.\n   * @return {Array} A plain array that contains the data to be visualized in the chart\n   */\n  Chartist.getDataArray = function (data, reverse, multi) {\n    // If the data should be reversed but isn't we need to reverse it\n    // If it's reversed but it shouldn't we need to reverse it back\n    // That's required to handle data updates correctly and to reflect the responsive configurations\n    if(reverse && !data.reversed || !reverse && data.reversed) {\n      Chartist.reverseData(data);\n      data.reversed = !data.reversed;\n    }\n\n    // Recursively walks through nested arrays and convert string values to numbers and objects with value properties\n    // to values. Check the tests in data core -> data normalization for a detailed specification of expected values\n    function recursiveConvert(value) {\n      if(Chartist.isFalseyButZero(value)) {\n        // This is a hole in data and we should return undefined\n        return undefined;\n      } else if((value.data || value) instanceof Array) {\n        return (value.data || value).map(recursiveConvert);\n      } else if(value.hasOwnProperty('value')) {\n        return recursiveConvert(value.value);\n      } else {\n        if(multi) {\n          var multiValue = {};\n\n          // Single series value arrays are assumed to specify the Y-Axis value\n          // For example: [1, 2] => [{x: undefined, y: 1}, {x: undefined, y: 2}]\n          // If multi is a string then it's assumed that it specified which dimension should be filled as default\n          if(typeof multi === 'string') {\n            multiValue[multi] = Chartist.getNumberOrUndefined(value);\n          } else {\n            multiValue.y = Chartist.getNumberOrUndefined(value);\n          }\n\n          multiValue.x = value.hasOwnProperty('x') ? Chartist.getNumberOrUndefined(value.x) : multiValue.x;\n          multiValue.y = value.hasOwnProperty('y') ? Chartist.getNumberOrUndefined(value.y) : multiValue.y;\n\n          return multiValue;\n\n        } else {\n          return Chartist.getNumberOrUndefined(value);\n        }\n      }\n    }\n\n    return data.series.map(recursiveConvert);\n  };\n\n  /**\n   * Converts a number into a padding object.\n   *\n   * @memberof Chartist.Core\n   * @param {Object|Number} padding\n   * @param {Number} [fallback] This value is used to fill missing values if a incomplete padding object was passed\n   * @returns {Object} Returns a padding object containing top, right, bottom, left properties filled with the padding number passed in as argument. If the argument is something else than a number (presumably already a correct padding object) then this argument is directly returned.\n   */\n  Chartist.normalizePadding = function(padding, fallback) {\n    fallback = fallback || 0;\n\n    return typeof padding === 'number' ? {\n      top: padding,\n      right: padding,\n      bottom: padding,\n      left: padding\n    } : {\n      top: typeof padding.top === 'number' ? padding.top : fallback,\n      right: typeof padding.right === 'number' ? padding.right : fallback,\n      bottom: typeof padding.bottom === 'number' ? padding.bottom : fallback,\n      left: typeof padding.left === 'number' ? padding.left : fallback\n    };\n  };\n\n  Chartist.getMetaData = function(series, index) {\n    var value = series.data ? series.data[index] : series[index];\n    return value ? Chartist.serialize(value.meta) : undefined;\n  };\n\n  /**\n   * Calculate the order of magnitude for the chart scale\n   *\n   * @memberof Chartist.Core\n   * @param {Number} value The value Range of the chart\n   * @return {Number} The order of magnitude\n   */\n  Chartist.orderOfMagnitude = function (value) {\n    return Math.floor(Math.log(Math.abs(value)) / Math.LN10);\n  };\n\n  /**\n   * Project a data length into screen coordinates (pixels)\n   *\n   * @memberof Chartist.Core\n   * @param {Object} axisLength The svg element for the chart\n   * @param {Number} length Single data value from a series array\n   * @param {Object} bounds All the values to set the bounds of the chart\n   * @return {Number} The projected data length in pixels\n   */\n  Chartist.projectLength = function (axisLength, length, bounds) {\n    return length / bounds.range * axisLength;\n  };\n\n  /**\n   * Get the height of the area in the chart for the data series\n   *\n   * @memberof Chartist.Core\n   * @param {Object} svg The svg element for the chart\n   * @param {Object} options The Object that contains all the optional values for the chart\n   * @return {Number} The height of the area in the chart for the data series\n   */\n  Chartist.getAvailableHeight = function (svg, options) {\n    return Math.max((Chartist.quantity(options.height).value || svg.height()) - (options.chartPadding.top +  options.chartPadding.bottom) - options.axisX.offset, 0);\n  };\n\n  /**\n   * Get highest and lowest value of data array. This Array contains the data that will be visualized in the chart.\n   *\n   * @memberof Chartist.Core\n   * @param {Array} data The array that contains the data to be visualized in the chart\n   * @param {Object} options The Object that contains the chart options\n   * @param {String} dimension Axis dimension 'x' or 'y' used to access the correct value and high / low configuration\n   * @return {Object} An object that contains the highest and lowest value that will be visualized on the chart.\n   */\n  Chartist.getHighLow = function (data, options, dimension) {\n    // TODO: Remove workaround for deprecated global high / low config. Axis high / low configuration is preferred\n    options = Chartist.extend({}, options, dimension ? options['axis' + dimension.toUpperCase()] : {});\n\n    var highLow = {\n        high: options.high === undefined ? -Number.MAX_VALUE : +options.high,\n        low: options.low === undefined ? Number.MAX_VALUE : +options.low\n      };\n    var findHigh = options.high === undefined;\n    var findLow = options.low === undefined;\n\n    // Function to recursively walk through arrays and find highest and lowest number\n    function recursiveHighLow(data) {\n      if(data === undefined) {\n        return undefined;\n      } else if(data instanceof Array) {\n        for (var i = 0; i < data.length; i++) {\n          recursiveHighLow(data[i]);\n        }\n      } else {\n        var value = dimension ? +data[dimension] : +data;\n\n        if (findHigh && value > highLow.high) {\n          highLow.high = value;\n        }\n\n        if (findLow && value < highLow.low) {\n          highLow.low = value;\n        }\n      }\n    }\n\n    // Start to find highest and lowest number recursively\n    if(findHigh || findLow) {\n      recursiveHighLow(data);\n    }\n\n    // Overrides of high / low based on reference value, it will make sure that the invisible reference value is\n    // used to generate the chart. This is useful when the chart always needs to contain the position of the\n    // invisible reference value in the view i.e. for bipolar scales.\n    if (options.referenceValue || options.referenceValue === 0) {\n      highLow.high = Math.max(options.referenceValue, highLow.high);\n      highLow.low = Math.min(options.referenceValue, highLow.low);\n    }\n\n    // If high and low are the same because of misconfiguration or flat data (only the same value) we need\n    // to set the high or low to 0 depending on the polarity\n    if (highLow.high <= highLow.low) {\n      // If both values are 0 we set high to 1\n      if (highLow.low === 0) {\n        highLow.high = 1;\n      } else if (highLow.low < 0) {\n        // If we have the same negative value for the bounds we set bounds.high to 0\n        highLow.high = 0;\n      } else if (highLow.high > 0) {\n        // If we have the same positive value for the bounds we set bounds.low to 0\n        highLow.low = 0;\n      } else {\n        // If data array was empty, values are Number.MAX_VALUE and -Number.MAX_VALUE. Set bounds to prevent errors\n        highLow.high = 1;\n        highLow.low = 0;\n      }\n    }\n\n    return highLow;\n  };\n\n  /**\n   * Checks if the value is a valid number or string with a number.\n   *\n   * @memberof Chartist.Core\n   * @param value\n   * @returns {Boolean}\n   */\n  Chartist.isNum = function(value) {\n    return !isNaN(value) && isFinite(value);\n  };\n\n  /**\n   * Returns true on all falsey values except the numeric value 0.\n   *\n   * @memberof Chartist.Core\n   * @param value\n   * @returns {boolean}\n   */\n  Chartist.isFalseyButZero = function(value) {\n    return !value && value !== 0;\n  };\n\n  /**\n   * Returns a number if the passed parameter is a valid number or the function will return undefined. On all other values than a valid number, this function will return undefined.\n   *\n   * @memberof Chartist.Core\n   * @param value\n   * @returns {*}\n   */\n  Chartist.getNumberOrUndefined = function(value) {\n    return isNaN(+value) ? undefined : +value;\n  };\n\n  /**\n   * Gets a value from a dimension `value.x` or `value.y` while returning value directly if it's a valid numeric value. If the value is not numeric and it's falsey this function will return undefined.\n   *\n   * @param value\n   * @param dimension\n   * @returns {*}\n   */\n  Chartist.getMultiValue = function(value, dimension) {\n    if(Chartist.isNum(value)) {\n      return +value;\n    } else if(value) {\n      return value[dimension || 'y'] || 0;\n    } else {\n      return 0;\n    }\n  };\n\n  /**\n   * Pollard Rho Algorithm to find smallest factor of an integer value. There are more efficient algorithms for factorization, but this one is quite efficient and not so complex.\n   *\n   * @memberof Chartist.Core\n   * @param {Number} num An integer number where the smallest factor should be searched for\n   * @returns {Number} The smallest integer factor of the parameter num.\n   */\n  Chartist.rho = function(num) {\n    if(num === 1) {\n      return num;\n    }\n\n    function gcd(p, q) {\n      if (p % q === 0) {\n        return q;\n      } else {\n        return gcd(q, p % q);\n      }\n    }\n\n    function f(x) {\n      return x * x + 1;\n    }\n\n    var x1 = 2, x2 = 2, divisor;\n    if (num % 2 === 0) {\n      return 2;\n    }\n\n    do {\n      x1 = f(x1) % num;\n      x2 = f(f(x2)) % num;\n      divisor = gcd(Math.abs(x1 - x2), num);\n    } while (divisor === 1);\n\n    return divisor;\n  };\n\n  /**\n   * Calculate and retrieve all the bounds for the chart and return them in one array\n   *\n   * @memberof Chartist.Core\n   * @param {Number} axisLength The length of the Axis used for\n   * @param {Object} highLow An object containing a high and low property indicating the value range of the chart.\n   * @param {Number} scaleMinSpace The minimum projected length a step should result in\n   * @param {Boolean} onlyInteger\n   * @return {Object} All the values to set the bounds of the chart\n   */\n  Chartist.getBounds = function (axisLength, highLow, scaleMinSpace, onlyInteger) {\n    var i,\n      optimizationCounter = 0,\n      newMin,\n      newMax,\n      bounds = {\n        high: highLow.high,\n        low: highLow.low\n      };\n\n    bounds.valueRange = bounds.high - bounds.low;\n    bounds.oom = Chartist.orderOfMagnitude(bounds.valueRange);\n    bounds.step = Math.pow(10, bounds.oom);\n    bounds.min = Math.floor(bounds.low / bounds.step) * bounds.step;\n    bounds.max = Math.ceil(bounds.high / bounds.step) * bounds.step;\n    bounds.range = bounds.max - bounds.min;\n    bounds.numberOfSteps = Math.round(bounds.range / bounds.step);\n\n    // Optimize scale step by checking if subdivision is possible based on horizontalGridMinSpace\n    // If we are already below the scaleMinSpace value we will scale up\n    var length = Chartist.projectLength(axisLength, bounds.step, bounds);\n    var scaleUp = length < scaleMinSpace;\n    var smallestFactor = onlyInteger ? Chartist.rho(bounds.range) : 0;\n\n    // First check if we should only use integer steps and if step 1 is still larger than scaleMinSpace so we can use 1\n    if(onlyInteger && Chartist.projectLength(axisLength, 1, bounds) >= scaleMinSpace) {\n      bounds.step = 1;\n    } else if(onlyInteger && smallestFactor < bounds.step && Chartist.projectLength(axisLength, smallestFactor, bounds) >= scaleMinSpace) {\n      // If step 1 was too small, we can try the smallest factor of range\n      // If the smallest factor is smaller than the current bounds.step and the projected length of smallest factor\n      // is larger than the scaleMinSpace we should go for it.\n      bounds.step = smallestFactor;\n    } else {\n      // Trying to divide or multiply by 2 and find the best step value\n      while (true) {\n        if (scaleUp && Chartist.projectLength(axisLength, bounds.step, bounds) <= scaleMinSpace) {\n          bounds.step *= 2;\n        } else if (!scaleUp && Chartist.projectLength(axisLength, bounds.step / 2, bounds) >= scaleMinSpace) {\n          bounds.step /= 2;\n          if(onlyInteger && bounds.step % 1 !== 0) {\n            bounds.step *= 2;\n            break;\n          }\n        } else {\n          break;\n        }\n\n        if(optimizationCounter++ > 1000) {\n          throw new Error('Exceeded maximum number of iterations while optimizing scale step!');\n        }\n      }\n    }\n\n    // step must not be less than EPSILON to create values that can be represented as floating number.\n    var EPSILON = 2.221E-16;\n    bounds.step = Math.max(bounds.step, EPSILON);\n\n    // Narrow min and max based on new step\n    newMin = bounds.min;\n    newMax = bounds.max;\n    while(newMin + bounds.step <= bounds.low) {\n      newMin += bounds.step;\n    }\n    while(newMax - bounds.step >= bounds.high) {\n      newMax -= bounds.step;\n    }\n    bounds.min = newMin;\n    bounds.max = newMax;\n    bounds.range = bounds.max - bounds.min;\n\n    var values = [];\n    for (i = bounds.min; i <= bounds.max; i += bounds.step) {\n      var value = Chartist.roundWithPrecision(i);\n      if (value !== values[values.length - 1]) {\n        values.push(i);\n      }\n    }\n    bounds.values = values;\n    return bounds;\n  };\n\n  /**\n   * Calculate cartesian coordinates of polar coordinates\n   *\n   * @memberof Chartist.Core\n   * @param {Number} centerX X-axis coordinates of center point of circle segment\n   * @param {Number} centerY X-axis coordinates of center point of circle segment\n   * @param {Number} radius Radius of circle segment\n   * @param {Number} angleInDegrees Angle of circle segment in degrees\n   * @return {{x:Number, y:Number}} Coordinates of point on circumference\n   */\n  Chartist.polarToCartesian = function (centerX, centerY, radius, angleInDegrees) {\n    var angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;\n\n    return {\n      x: centerX + (radius * Math.cos(angleInRadians)),\n      y: centerY + (radius * Math.sin(angleInRadians))\n    };\n  };\n\n  /**\n   * Initialize chart drawing rectangle (area where chart is drawn) x1,y1 = bottom left / x2,y2 = top right\n   *\n   * @memberof Chartist.Core\n   * @param {Object} svg The svg element for the chart\n   * @param {Object} options The Object that contains all the optional values for the chart\n   * @param {Number} [fallbackPadding] The fallback padding if partial padding objects are used\n   * @return {Object} The chart rectangles coordinates inside the svg element plus the rectangles measurements\n   */\n  Chartist.createChartRect = function (svg, options, fallbackPadding) {\n    var hasAxis = !!(options.axisX || options.axisY);\n    var yAxisOffset = hasAxis ? options.axisY.offset : 0;\n    var xAxisOffset = hasAxis ? options.axisX.offset : 0;\n    // If width or height results in invalid value (including 0) we fallback to the unitless settings or even 0\n    var width = svg.width() || Chartist.quantity(options.width).value || 0;\n    var height = svg.height() || Chartist.quantity(options.height).value || 0;\n    var normalizedPadding = Chartist.normalizePadding(options.chartPadding, fallbackPadding);\n\n    // If settings were to small to cope with offset (legacy) and padding, we'll adjust\n    width = Math.max(width, yAxisOffset + normalizedPadding.left + normalizedPadding.right);\n    height = Math.max(height, xAxisOffset + normalizedPadding.top + normalizedPadding.bottom);\n\n    var chartRect = {\n      padding: normalizedPadding,\n      width: function () {\n        return this.x2 - this.x1;\n      },\n      height: function () {\n        return this.y1 - this.y2;\n      }\n    };\n\n    if(hasAxis) {\n      if (options.axisX.position === 'start') {\n        chartRect.y2 = normalizedPadding.top + xAxisOffset;\n        chartRect.y1 = Math.max(height - normalizedPadding.bottom, chartRect.y2 + 1);\n      } else {\n        chartRect.y2 = normalizedPadding.top;\n        chartRect.y1 = Math.max(height - normalizedPadding.bottom - xAxisOffset, chartRect.y2 + 1);\n      }\n\n      if (options.axisY.position === 'start') {\n        chartRect.x1 = normalizedPadding.left + yAxisOffset;\n        chartRect.x2 = Math.max(width - normalizedPadding.right, chartRect.x1 + 1);\n      } else {\n        chartRect.x1 = normalizedPadding.left;\n        chartRect.x2 = Math.max(width - normalizedPadding.right - yAxisOffset, chartRect.x1 + 1);\n      }\n    } else {\n      chartRect.x1 = normalizedPadding.left;\n      chartRect.x2 = Math.max(width - normalizedPadding.right, chartRect.x1 + 1);\n      chartRect.y2 = normalizedPadding.top;\n      chartRect.y1 = Math.max(height - normalizedPadding.bottom, chartRect.y2 + 1);\n    }\n\n    return chartRect;\n  };\n\n  /**\n   * Creates a grid line based on a projected value.\n   *\n   * @memberof Chartist.Core\n   * @param position\n   * @param index\n   * @param axis\n   * @param offset\n   * @param length\n   * @param group\n   * @param classes\n   * @param eventEmitter\n   */\n  Chartist.createGrid = function(position, index, axis, offset, length, group, classes, eventEmitter) {\n    var positionalData = {};\n    positionalData[axis.units.pos + '1'] = position;\n    positionalData[axis.units.pos + '2'] = position;\n    positionalData[axis.counterUnits.pos + '1'] = offset;\n    positionalData[axis.counterUnits.pos + '2'] = offset + length;\n\n    var gridElement = group.elem('line', positionalData, classes.join(' '));\n\n    // Event for grid draw\n    eventEmitter.emit('draw',\n      Chartist.extend({\n        type: 'grid',\n        axis: axis,\n        index: index,\n        group: group,\n        element: gridElement\n      }, positionalData)\n    );\n  };\n\n  /**\n   * Creates a label based on a projected value and an axis.\n   *\n   * @memberof Chartist.Core\n   * @param position\n   * @param length\n   * @param index\n   * @param labels\n   * @param axis\n   * @param axisOffset\n   * @param labelOffset\n   * @param group\n   * @param classes\n   * @param useForeignObject\n   * @param eventEmitter\n   */\n  Chartist.createLabel = function(position, length, index, labels, axis, axisOffset, labelOffset, group, classes, useForeignObject, eventEmitter) {\n    var labelElement;\n    var positionalData = {};\n\n    positionalData[axis.units.pos] = position + labelOffset[axis.units.pos];\n    positionalData[axis.counterUnits.pos] = labelOffset[axis.counterUnits.pos];\n    positionalData[axis.units.len] = length;\n    positionalData[axis.counterUnits.len] = Math.max(0, axisOffset - 10);\n\n    if(useForeignObject) {\n      // We need to set width and height explicitly to px as span will not expand with width and height being\n      // 100% in all browsers\n      var content = '<span class=\"' + classes.join(' ') + '\" style=\"' +\n        axis.units.len + ': ' + Math.round(positionalData[axis.units.len]) + 'px; ' +\n        axis.counterUnits.len + ': ' + Math.round(positionalData[axis.counterUnits.len]) + 'px\">' +\n        labels[index] + '</span>';\n\n      labelElement = group.foreignObject(content, Chartist.extend({\n        style: 'overflow: visible;'\n      }, positionalData));\n    } else {\n      labelElement = group.elem('text', positionalData, classes.join(' ')).text(labels[index]);\n    }\n\n    eventEmitter.emit('draw', Chartist.extend({\n      type: 'label',\n      axis: axis,\n      index: index,\n      group: group,\n      element: labelElement,\n      text: labels[index]\n    }, positionalData));\n  };\n\n  /**\n   * Helper to read series specific options from options object. It automatically falls back to the global option if\n   * there is no option in the series options.\n   *\n   * @param {Object} series Series object\n   * @param {Object} options Chartist options object\n   * @param {string} key The options key that should be used to obtain the options\n   * @returns {*}\n   */\n  Chartist.getSeriesOption = function(series, options, key) {\n    if(series.name && options.series && options.series[series.name]) {\n      var seriesOptions = options.series[series.name];\n      return seriesOptions.hasOwnProperty(key) ? seriesOptions[key] : options[key];\n    } else {\n      return options[key];\n    }\n  };\n\n  /**\n   * Provides options handling functionality with callback for options changes triggered by responsive options and media query matches\n   *\n   * @memberof Chartist.Core\n   * @param {Object} options Options set by user\n   * @param {Array} responsiveOptions Optional functions to add responsive behavior to chart\n   * @param {Object} eventEmitter The event emitter that will be used to emit the options changed events\n   * @return {Object} The consolidated options object from the defaults, base and matching responsive options\n   */\n  Chartist.optionsProvider = function (options, responsiveOptions, eventEmitter) {\n    var baseOptions = Chartist.extend({}, options),\n      currentOptions,\n      mediaQueryListeners = [],\n      i;\n\n    function updateCurrentOptions(mediaEvent) {\n      var previousOptions = currentOptions;\n      currentOptions = Chartist.extend({}, baseOptions);\n\n      if (responsiveOptions) {\n        for (i = 0; i < responsiveOptions.length; i++) {\n          var mql = window.matchMedia(responsiveOptions[i][0]);\n          if (mql.matches) {\n            currentOptions = Chartist.extend(currentOptions, responsiveOptions[i][1]);\n          }\n        }\n      }\n\n      if(eventEmitter && mediaEvent) {\n        eventEmitter.emit('optionsChanged', {\n          previousOptions: previousOptions,\n          currentOptions: currentOptions\n        });\n      }\n    }\n\n    function removeMediaQueryListeners() {\n      mediaQueryListeners.forEach(function(mql) {\n        mql.removeListener(updateCurrentOptions);\n      });\n    }\n\n    if (!window.matchMedia) {\n      throw 'window.matchMedia not found! Make sure you\\'re using a polyfill.';\n    } else if (responsiveOptions) {\n\n      for (i = 0; i < responsiveOptions.length; i++) {\n        var mql = window.matchMedia(responsiveOptions[i][0]);\n        mql.addListener(updateCurrentOptions);\n        mediaQueryListeners.push(mql);\n      }\n    }\n    // Execute initially without an event argument so we get the correct options\n    updateCurrentOptions();\n\n    return {\n      removeMediaQueryListeners: removeMediaQueryListeners,\n      getCurrentOptions: function getCurrentOptions() {\n        return Chartist.extend({}, currentOptions);\n      }\n    };\n  };\n\n\n  /**\n   * Splits a list of coordinates and associated values into segments. Each returned segment contains a pathCoordinates\n   * valueData property describing the segment.\n   *\n   * With the default options, segments consist of contiguous sets of points that do not have an undefined value. Any\n   * points with undefined values are discarded.\n   *\n   * **Options**\n   * The following options are used to determine how segments are formed\n   * ```javascript\n   * var options = {\n   *   // If fillHoles is true, undefined values are simply discarded without creating a new segment. Assuming other options are default, this returns single segment.\n   *   fillHoles: false,\n   *   // If increasingX is true, the coordinates in all segments have strictly increasing x-values.\n   *   increasingX: false\n   * };\n   * ```\n   *\n   * @memberof Chartist.Core\n   * @param {Array} pathCoordinates List of point coordinates to be split in the form [x1, y1, x2, y2 ... xn, yn]\n   * @param {Array} values List of associated point values in the form [v1, v2 .. vn]\n   * @param {Object} options Options set by user\n   * @return {Array} List of segments, each containing a pathCoordinates and valueData property.\n   */\n  Chartist.splitIntoSegments = function(pathCoordinates, valueData, options) {\n    var defaultOptions = {\n      increasingX: false,\n      fillHoles: false\n    };\n\n    options = Chartist.extend({}, defaultOptions, options);\n\n    var segments = [];\n    var hole = true;\n\n    for(var i = 0; i < pathCoordinates.length; i += 2) {\n      // If this value is a \"hole\" we set the hole flag\n      if(valueData[i / 2].value === undefined) {\n        if(!options.fillHoles) {\n          hole = true;\n        }\n      } else {\n        if(options.increasingX && i >= 2 && pathCoordinates[i] <= pathCoordinates[i-2]) {\n          // X is not increasing, so we need to make sure we start a new segment\n          hole = true;\n        }\n\n\n        // If it's a valid value we need to check if we're coming out of a hole and create a new empty segment\n        if(hole) {\n          segments.push({\n            pathCoordinates: [],\n            valueData: []\n          });\n          // As we have a valid value now, we are not in a \"hole\" anymore\n          hole = false;\n        }\n\n        // Add to the segment pathCoordinates and valueData\n        segments[segments.length - 1].pathCoordinates.push(pathCoordinates[i], pathCoordinates[i + 1]);\n        segments[segments.length - 1].valueData.push(valueData[i / 2]);\n      }\n    }\n\n    return segments;\n  };\n}(window, document, Chartist));\n;/**\n * Chartist path interpolation functions.\n *\n * @module Chartist.Interpolation\n */\n/* global Chartist */\n(function(window, document, Chartist) {\n  'use strict';\n\n  Chartist.Interpolation = {};\n\n  /**\n   * This interpolation function does not smooth the path and the result is only containing lines and no curves.\n   *\n   * @example\n   * var chart = new Chartist.Line('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5],\n   *   series: [[1, 2, 8, 1, 7]]\n   * }, {\n   *   lineSmooth: Chartist.Interpolation.none({\n   *     fillHoles: false\n   *   })\n   * });\n   *\n   *\n   * @memberof Chartist.Interpolation\n   * @return {Function}\n   */\n  Chartist.Interpolation.none = function(options) {\n    var defaultOptions = {\n      fillHoles: false\n    };\n    options = Chartist.extend({}, defaultOptions, options);\n    return function none(pathCoordinates, valueData) {\n      var path = new Chartist.Svg.Path();\n      var hole = true;\n\n      for(var i = 0; i < pathCoordinates.length; i += 2) {\n        var currX = pathCoordinates[i];\n        var currY = pathCoordinates[i + 1];\n        var currData = valueData[i / 2];\n\n        if(currData.value !== undefined) {\n\n          if(hole) {\n            path.move(currX, currY, false, currData);\n          } else {\n            path.line(currX, currY, false, currData);\n          }\n\n          hole = false;\n        } else if(!options.fillHoles) {\n          hole = true;\n        }\n      }\n\n      return path;\n    };\n  };\n\n  /**\n   * Simple smoothing creates horizontal handles that are positioned with a fraction of the length between two data points. You can use the divisor option to specify the amount of smoothing.\n   *\n   * Simple smoothing can be used instead of `Chartist.Smoothing.cardinal` if you'd like to get rid of the artifacts it produces sometimes. Simple smoothing produces less flowing lines but is accurate by hitting the points and it also doesn't swing below or above the given data point.\n   *\n   * All smoothing functions within Chartist are factory functions that accept an options parameter. The simple interpolation function accepts one configuration parameter `divisor`, between 1 and ∞, which controls the smoothing characteristics.\n   *\n   * @example\n   * var chart = new Chartist.Line('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5],\n   *   series: [[1, 2, 8, 1, 7]]\n   * }, {\n   *   lineSmooth: Chartist.Interpolation.simple({\n   *     divisor: 2,\n   *     fillHoles: false\n   *   })\n   * });\n   *\n   *\n   * @memberof Chartist.Interpolation\n   * @param {Object} options The options of the simple interpolation factory function.\n   * @return {Function}\n   */\n  Chartist.Interpolation.simple = function(options) {\n    var defaultOptions = {\n      divisor: 2,\n      fillHoles: false\n    };\n    options = Chartist.extend({}, defaultOptions, options);\n\n    var d = 1 / Math.max(1, options.divisor);\n\n    return function simple(pathCoordinates, valueData) {\n      var path = new Chartist.Svg.Path();\n      var prevX, prevY, prevData;\n\n      for(var i = 0; i < pathCoordinates.length; i += 2) {\n        var currX = pathCoordinates[i];\n        var currY = pathCoordinates[i + 1];\n        var length = (currX - prevX) * d;\n        var currData = valueData[i / 2];\n\n        if(currData.value !== undefined) {\n\n          if(prevData === undefined) {\n            path.move(currX, currY, false, currData);\n          } else {\n            path.curve(\n              prevX + length,\n              prevY,\n              currX - length,\n              currY,\n              currX,\n              currY,\n              false,\n              currData\n            );\n          }\n\n          prevX = currX;\n          prevY = currY;\n          prevData = currData;\n        } else if(!options.fillHoles) {\n          prevX = currX = prevData = undefined;\n        }\n      }\n\n      return path;\n    };\n  };\n\n  /**\n   * Cardinal / Catmull-Rome spline interpolation is the default smoothing function in Chartist. It produces nice results where the splines will always meet the points. It produces some artifacts though when data values are increased or decreased rapidly. The line may not follow a very accurate path and if the line should be accurate this smoothing function does not produce the best results.\n   *\n   * Cardinal splines can only be created if there are more than two data points. If this is not the case this smoothing will fallback to `Chartist.Smoothing.none`.\n   *\n   * All smoothing functions within Chartist are factory functions that accept an options parameter. The cardinal interpolation function accepts one configuration parameter `tension`, between 0 and 1, which controls the smoothing intensity.\n   *\n   * @example\n   * var chart = new Chartist.Line('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5],\n   *   series: [[1, 2, 8, 1, 7]]\n   * }, {\n   *   lineSmooth: Chartist.Interpolation.cardinal({\n   *     tension: 1,\n   *     fillHoles: false\n   *   })\n   * });\n   *\n   * @memberof Chartist.Interpolation\n   * @param {Object} options The options of the cardinal factory function.\n   * @return {Function}\n   */\n  Chartist.Interpolation.cardinal = function(options) {\n    var defaultOptions = {\n      tension: 1,\n      fillHoles: false\n    };\n\n    options = Chartist.extend({}, defaultOptions, options);\n\n    var t = Math.min(1, Math.max(0, options.tension)),\n      c = 1 - t;\n\n    return function cardinal(pathCoordinates, valueData) {\n      // First we try to split the coordinates into segments\n      // This is necessary to treat \"holes\" in line charts\n      var segments = Chartist.splitIntoSegments(pathCoordinates, valueData, {\n        fillHoles: options.fillHoles\n      });\n\n      if(!segments.length) {\n        // If there were no segments return 'Chartist.Interpolation.none'\n        return Chartist.Interpolation.none()([]);\n      } else if(segments.length > 1) {\n        // If the split resulted in more that one segment we need to interpolate each segment individually and join them\n        // afterwards together into a single path.\n          var paths = [];\n        // For each segment we will recurse the cardinal function\n        segments.forEach(function(segment) {\n          paths.push(cardinal(segment.pathCoordinates, segment.valueData));\n        });\n        // Join the segment path data into a single path and return\n        return Chartist.Svg.Path.join(paths);\n      } else {\n        // If there was only one segment we can proceed regularly by using pathCoordinates and valueData from the first\n        // segment\n        pathCoordinates = segments[0].pathCoordinates;\n        valueData = segments[0].valueData;\n\n        // If less than two points we need to fallback to no smoothing\n        if(pathCoordinates.length <= 4) {\n          return Chartist.Interpolation.none()(pathCoordinates, valueData);\n        }\n\n        var path = new Chartist.Svg.Path().move(pathCoordinates[0], pathCoordinates[1], false, valueData[0]),\n          z;\n\n        for (var i = 0, iLen = pathCoordinates.length; iLen - 2 * !z > i; i += 2) {\n          var p = [\n            {x: +pathCoordinates[i - 2], y: +pathCoordinates[i - 1]},\n            {x: +pathCoordinates[i], y: +pathCoordinates[i + 1]},\n            {x: +pathCoordinates[i + 2], y: +pathCoordinates[i + 3]},\n            {x: +pathCoordinates[i + 4], y: +pathCoordinates[i + 5]}\n          ];\n          if (z) {\n            if (!i) {\n              p[0] = {x: +pathCoordinates[iLen - 2], y: +pathCoordinates[iLen - 1]};\n            } else if (iLen - 4 === i) {\n              p[3] = {x: +pathCoordinates[0], y: +pathCoordinates[1]};\n            } else if (iLen - 2 === i) {\n              p[2] = {x: +pathCoordinates[0], y: +pathCoordinates[1]};\n              p[3] = {x: +pathCoordinates[2], y: +pathCoordinates[3]};\n            }\n          } else {\n            if (iLen - 4 === i) {\n              p[3] = p[2];\n            } else if (!i) {\n              p[0] = {x: +pathCoordinates[i], y: +pathCoordinates[i + 1]};\n            }\n          }\n\n          path.curve(\n            (t * (-p[0].x + 6 * p[1].x + p[2].x) / 6) + (c * p[2].x),\n            (t * (-p[0].y + 6 * p[1].y + p[2].y) / 6) + (c * p[2].y),\n            (t * (p[1].x + 6 * p[2].x - p[3].x) / 6) + (c * p[2].x),\n            (t * (p[1].y + 6 * p[2].y - p[3].y) / 6) + (c * p[2].y),\n            p[2].x,\n            p[2].y,\n            false,\n            valueData[(i + 2) / 2]\n          );\n        }\n\n        return path;\n      }\n    };\n  };\n\n  /**\n   * Monotone Cubic spline interpolation produces a smooth curve which preserves monotonicity. Unlike cardinal splines, the curve will not extend beyond the range of y-values of the original data points.\n   *\n   * Monotone Cubic splines can only be created if there are more than two data points. If this is not the case this smoothing will fallback to `Chartist.Smoothing.none`.\n   *\n   * The x-values of subsequent points must be increasing to fit a Monotone Cubic spline. If this condition is not met for a pair of adjacent points, then there will be a break in the curve between those data points.\n   *\n   * All smoothing functions within Chartist are factory functions that accept an options parameter.\n   *\n   * @example\n   * var chart = new Chartist.Line('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5],\n   *   series: [[1, 2, 8, 1, 7]]\n   * }, {\n   *   lineSmooth: Chartist.Interpolation.monotoneCubic({\n   *     fillHoles: false\n   *   })\n   * });\n   *\n   * @memberof Chartist.Interpolation\n   * @param {Object} options The options of the monotoneCubic factory function.\n   * @return {Function}\n   */\n  Chartist.Interpolation.monotoneCubic = function(options) {\n    var defaultOptions = {\n      fillHoles: false\n    };\n\n    options = Chartist.extend({}, defaultOptions, options);\n\n    return function monotoneCubic(pathCoordinates, valueData) {\n      // First we try to split the coordinates into segments\n      // This is necessary to treat \"holes\" in line charts\n      var segments = Chartist.splitIntoSegments(pathCoordinates, valueData, {\n        fillHoles: options.fillHoles,\n        increasingX: true\n      });\n\n      if(!segments.length) {\n        // If there were no segments return 'Chartist.Interpolation.none'\n        return Chartist.Interpolation.none()([]);\n      } else if(segments.length > 1) {\n        // If the split resulted in more that one segment we need to interpolate each segment individually and join them\n        // afterwards together into a single path.\n          var paths = [];\n        // For each segment we will recurse the monotoneCubic fn function\n        segments.forEach(function(segment) {\n          paths.push(monotoneCubic(segment.pathCoordinates, segment.valueData));\n        });\n        // Join the segment path data into a single path and return\n        return Chartist.Svg.Path.join(paths);\n      } else {\n        // If there was only one segment we can proceed regularly by using pathCoordinates and valueData from the first\n        // segment\n        pathCoordinates = segments[0].pathCoordinates;\n        valueData = segments[0].valueData;\n\n        // If less than three points we need to fallback to no smoothing\n        if(pathCoordinates.length <= 4) {\n          return Chartist.Interpolation.none()(pathCoordinates, valueData);\n        }\n\n        var xs = [],\n          ys = [],\n          i,\n          n = pathCoordinates.length / 2,\n          ms = [],\n          ds = [], dys = [], dxs = [],\n          path;\n\n        // Populate x and y coordinates into separate arrays, for readability\n\n        for(i = 0; i < n; i++) {\n          xs[i] = pathCoordinates[i * 2];\n          ys[i] = pathCoordinates[i * 2 + 1];\n        }\n\n        // Calculate deltas and derivative\n\n        for(i = 0; i < n - 1; i++) {\n          dys[i] = ys[i + 1] - ys[i];\n          dxs[i] = xs[i + 1] - xs[i];\n          ds[i] = dys[i] / dxs[i];\n        }\n\n        // Determine desired slope (m) at each point using Fritsch-Carlson method\n        // See: http://math.stackexchange.com/questions/45218/implementation-of-monotone-cubic-interpolation\n\n        ms[0] = ds[0];\n        ms[n - 1] = ds[n - 2];\n\n        for(i = 1; i < n - 1; i++) {\n          if(ds[i] === 0 || ds[i - 1] === 0 || (ds[i - 1] > 0) !== (ds[i] > 0)) {\n            ms[i] = 0;\n          } else {\n            ms[i] = 3 * (dxs[i - 1] + dxs[i]) / (\n              (2 * dxs[i] + dxs[i - 1]) / ds[i - 1] +\n              (dxs[i] + 2 * dxs[i - 1]) / ds[i]);\n\n            if(!isFinite(ms[i])) {\n              ms[i] = 0;\n            }\n          }\n        }\n\n        // Now build a path from the slopes\n\n        path = new Chartist.Svg.Path().move(xs[0], ys[0], false, valueData[0]);\n\n        for(i = 0; i < n - 1; i++) {\n          path.curve(\n            // First control point\n            xs[i] + dxs[i] / 3,\n            ys[i] + ms[i] * dxs[i] / 3,\n            // Second control point\n            xs[i + 1] - dxs[i] / 3,\n            ys[i + 1] - ms[i + 1] * dxs[i] / 3,\n            // End point\n            xs[i + 1],\n            ys[i + 1],\n\n            false,\n            valueData[i + 1]\n          );\n        }\n\n        return path;\n      }\n    };\n  };\n\n  /**\n   * Step interpolation will cause the line chart to move in steps rather than diagonal or smoothed lines. This interpolation will create additional points that will also be drawn when the `showPoint` option is enabled.\n   *\n   * All smoothing functions within Chartist are factory functions that accept an options parameter. The step interpolation function accepts one configuration parameter `postpone`, that can be `true` or `false`. The default value is `true` and will cause the step to occur where the value actually changes. If a different behaviour is needed where the step is shifted to the left and happens before the actual value, this option can be set to `false`.\n   *\n   * @example\n   * var chart = new Chartist.Line('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5],\n   *   series: [[1, 2, 8, 1, 7]]\n   * }, {\n   *   lineSmooth: Chartist.Interpolation.step({\n   *     postpone: true,\n   *     fillHoles: false\n   *   })\n   * });\n   *\n   * @memberof Chartist.Interpolation\n   * @param options\n   * @returns {Function}\n   */\n  Chartist.Interpolation.step = function(options) {\n    var defaultOptions = {\n      postpone: true,\n      fillHoles: false\n    };\n\n    options = Chartist.extend({}, defaultOptions, options);\n\n    return function step(pathCoordinates, valueData) {\n      var path = new Chartist.Svg.Path();\n\n      var prevX, prevY, prevData;\n\n      for (var i = 0; i < pathCoordinates.length; i += 2) {\n        var currX = pathCoordinates[i];\n        var currY = pathCoordinates[i + 1];\n        var currData = valueData[i / 2];\n\n        // If the current point is also not a hole we can draw the step lines\n        if(currData.value !== undefined) {\n          if(prevData === undefined) {\n            path.move(currX, currY, false, currData);\n          } else {\n            if(options.postpone) {\n              // If postponed we should draw the step line with the value of the previous value\n              path.line(currX, prevY, false, prevData);\n            } else {\n              // If not postponed we should draw the step line with the value of the current value\n              path.line(prevX, currY, false, currData);\n            }\n            // Line to the actual point (this should only be a Y-Axis movement\n            path.line(currX, currY, false, currData);\n          }\n\n          prevX = currX;\n          prevY = currY;\n          prevData = currData;\n        } else if(!options.fillHoles) {\n          prevX = prevY = prevData = undefined;\n        }\n      }\n\n      return path;\n    };\n  };\n\n}(window, document, Chartist));\n;/**\n * A very basic event module that helps to generate and catch events.\n *\n * @module Chartist.Event\n */\n/* global Chartist */\n(function (window, document, Chartist) {\n  'use strict';\n\n  Chartist.EventEmitter = function () {\n    var handlers = [];\n\n    /**\n     * Add an event handler for a specific event\n     *\n     * @memberof Chartist.Event\n     * @param {String} event The event name\n     * @param {Function} handler A event handler function\n     */\n    function addEventHandler(event, handler) {\n      handlers[event] = handlers[event] || [];\n      handlers[event].push(handler);\n    }\n\n    /**\n     * Remove an event handler of a specific event name or remove all event handlers for a specific event.\n     *\n     * @memberof Chartist.Event\n     * @param {String} event The event name where a specific or all handlers should be removed\n     * @param {Function} [handler] An optional event handler function. If specified only this specific handler will be removed and otherwise all handlers are removed.\n     */\n    function removeEventHandler(event, handler) {\n      // Only do something if there are event handlers with this name existing\n      if(handlers[event]) {\n        // If handler is set we will look for a specific handler and only remove this\n        if(handler) {\n          handlers[event].splice(handlers[event].indexOf(handler), 1);\n          if(handlers[event].length === 0) {\n            delete handlers[event];\n          }\n        } else {\n          // If no handler is specified we remove all handlers for this event\n          delete handlers[event];\n        }\n      }\n    }\n\n    /**\n     * Use this function to emit an event. All handlers that are listening for this event will be triggered with the data parameter.\n     *\n     * @memberof Chartist.Event\n     * @param {String} event The event name that should be triggered\n     * @param {*} data Arbitrary data that will be passed to the event handler callback functions\n     */\n    function emit(event, data) {\n      // Only do something if there are event handlers with this name existing\n      if(handlers[event]) {\n        handlers[event].forEach(function(handler) {\n          handler(data);\n        });\n      }\n\n      // Emit event to star event handlers\n      if(handlers['*']) {\n        handlers['*'].forEach(function(starHandler) {\n          starHandler(event, data);\n        });\n      }\n    }\n\n    return {\n      addEventHandler: addEventHandler,\n      removeEventHandler: removeEventHandler,\n      emit: emit\n    };\n  };\n\n}(window, document, Chartist));\n;/**\n * This module provides some basic prototype inheritance utilities.\n *\n * @module Chartist.Class\n */\n/* global Chartist */\n(function(window, document, Chartist) {\n  'use strict';\n\n  function listToArray(list) {\n    var arr = [];\n    if (list.length) {\n      for (var i = 0; i < list.length; i++) {\n        arr.push(list[i]);\n      }\n    }\n    return arr;\n  }\n\n  /**\n   * Method to extend from current prototype.\n   *\n   * @memberof Chartist.Class\n   * @param {Object} properties The object that serves as definition for the prototype that gets created for the new class. This object should always contain a constructor property that is the desired constructor for the newly created class.\n   * @param {Object} [superProtoOverride] By default extens will use the current class prototype or Chartist.class. With this parameter you can specify any super prototype that will be used.\n   * @return {Function} Constructor function of the new class\n   *\n   * @example\n   * var Fruit = Class.extend({\n     * color: undefined,\n     *   sugar: undefined,\n     *\n     *   constructor: function(color, sugar) {\n     *     this.color = color;\n     *     this.sugar = sugar;\n     *   },\n     *\n     *   eat: function() {\n     *     this.sugar = 0;\n     *     return this;\n     *   }\n     * });\n   *\n   * var Banana = Fruit.extend({\n     *   length: undefined,\n     *\n     *   constructor: function(length, sugar) {\n     *     Banana.super.constructor.call(this, 'Yellow', sugar);\n     *     this.length = length;\n     *   }\n     * });\n   *\n   * var banana = new Banana(20, 40);\n   * console.log('banana instanceof Fruit', banana instanceof Fruit);\n   * console.log('Fruit is prototype of banana', Fruit.prototype.isPrototypeOf(banana));\n   * console.log('bananas prototype is Fruit', Object.getPrototypeOf(banana) === Fruit.prototype);\n   * console.log(banana.sugar);\n   * console.log(banana.eat().sugar);\n   * console.log(banana.color);\n   */\n  function extend(properties, superProtoOverride) {\n    var superProto = superProtoOverride || this.prototype || Chartist.Class;\n    var proto = Object.create(superProto);\n\n    Chartist.Class.cloneDefinitions(proto, properties);\n\n    var constr = function() {\n      var fn = proto.constructor || function () {},\n        instance;\n\n      // If this is linked to the Chartist namespace the constructor was not called with new\n      // To provide a fallback we will instantiate here and return the instance\n      instance = this === Chartist ? Object.create(proto) : this;\n      fn.apply(instance, Array.prototype.slice.call(arguments, 0));\n\n      // If this constructor was not called with new we need to return the instance\n      // This will not harm when the constructor has been called with new as the returned value is ignored\n      return instance;\n    };\n\n    constr.prototype = proto;\n    constr.super = superProto;\n    constr.extend = this.extend;\n\n    return constr;\n  }\n\n  // Variable argument list clones args > 0 into args[0] and retruns modified args[0]\n  function cloneDefinitions() {\n    var args = listToArray(arguments);\n    var target = args[0];\n\n    args.splice(1, args.length - 1).forEach(function (source) {\n      Object.getOwnPropertyNames(source).forEach(function (propName) {\n        // If this property already exist in target we delete it first\n        delete target[propName];\n        // Define the property with the descriptor from source\n        Object.defineProperty(target, propName,\n          Object.getOwnPropertyDescriptor(source, propName));\n      });\n    });\n\n    return target;\n  }\n\n  Chartist.Class = {\n    extend: extend,\n    cloneDefinitions: cloneDefinitions\n  };\n\n}(window, document, Chartist));\n;/**\n * Base for all chart types. The methods in Chartist.Base are inherited to all chart types.\n *\n * @module Chartist.Base\n */\n/* global Chartist */\n(function(window, document, Chartist) {\n  'use strict';\n\n  // TODO: Currently we need to re-draw the chart on window resize. This is usually very bad and will affect performance.\n  // This is done because we can't work with relative coordinates when drawing the chart because SVG Path does not\n  // work with relative positions yet. We need to check if we can do a viewBox hack to switch to percentage.\n  // See http://mozilla.6506.n7.nabble.com/Specyfing-paths-with-percentages-unit-td247474.html\n  // Update: can be done using the above method tested here: http://codepen.io/gionkunz/pen/KDvLj\n  // The problem is with the label offsets that can't be converted into percentage and affecting the chart container\n  /**\n   * Updates the chart which currently does a full reconstruction of the SVG DOM\n   *\n   * @param {Object} [data] Optional data you'd like to set for the chart before it will update. If not specified the update method will use the data that is already configured with the chart.\n   * @param {Object} [options] Optional options you'd like to add to the previous options for the chart before it will update. If not specified the update method will use the options that have been already configured with the chart.\n   * @param {Boolean} [override] If set to true, the passed options will be used to extend the options that have been configured already. Otherwise the chart default options will be used as the base\n   * @memberof Chartist.Base\n   */\n  function update(data, options, override) {\n    if(data) {\n      this.data = data;\n      // Event for data transformation that allows to manipulate the data before it gets rendered in the charts\n      this.eventEmitter.emit('data', {\n        type: 'update',\n        data: this.data\n      });\n    }\n\n    if(options) {\n      this.options = Chartist.extend({}, override ? this.options : this.defaultOptions, options);\n\n      // If chartist was not initialized yet, we just set the options and leave the rest to the initialization\n      // Otherwise we re-create the optionsProvider at this point\n      if(!this.initializeTimeoutId) {\n        this.optionsProvider.removeMediaQueryListeners();\n        this.optionsProvider = Chartist.optionsProvider(this.options, this.responsiveOptions, this.eventEmitter);\n      }\n    }\n\n    // Only re-created the chart if it has been initialized yet\n    if(!this.initializeTimeoutId) {\n      this.createChart(this.optionsProvider.getCurrentOptions());\n    }\n\n    // Return a reference to the chart object to chain up calls\n    return this;\n  }\n\n  /**\n   * This method can be called on the API object of each chart and will un-register all event listeners that were added to other components. This currently includes a window.resize listener as well as media query listeners if any responsive options have been provided. Use this function if you need to destroy and recreate Chartist charts dynamically.\n   *\n   * @memberof Chartist.Base\n   */\n  function detach() {\n    // Only detach if initialization already occurred on this chart. If this chart still hasn't initialized (therefore\n    // the initializationTimeoutId is still a valid timeout reference, we will clear the timeout\n    if(!this.initializeTimeoutId) {\n      window.removeEventListener('resize', this.resizeListener);\n      this.optionsProvider.removeMediaQueryListeners();\n    } else {\n      window.clearTimeout(this.initializeTimeoutId);\n    }\n\n    return this;\n  }\n\n  /**\n   * Use this function to register event handlers. The handler callbacks are synchronous and will run in the main thread rather than the event loop.\n   *\n   * @memberof Chartist.Base\n   * @param {String} event Name of the event. Check the examples for supported events.\n   * @param {Function} handler The handler function that will be called when an event with the given name was emitted. This function will receive a data argument which contains event data. See the example for more details.\n   */\n  function on(event, handler) {\n    this.eventEmitter.addEventHandler(event, handler);\n    return this;\n  }\n\n  /**\n   * Use this function to un-register event handlers. If the handler function parameter is omitted all handlers for the given event will be un-registered.\n   *\n   * @memberof Chartist.Base\n   * @param {String} event Name of the event for which a handler should be removed\n   * @param {Function} [handler] The handler function that that was previously used to register a new event handler. This handler will be removed from the event handler list. If this parameter is omitted then all event handlers for the given event are removed from the list.\n   */\n  function off(event, handler) {\n    this.eventEmitter.removeEventHandler(event, handler);\n    return this;\n  }\n\n  function initialize() {\n    // Add window resize listener that re-creates the chart\n    window.addEventListener('resize', this.resizeListener);\n\n    // Obtain current options based on matching media queries (if responsive options are given)\n    // This will also register a listener that is re-creating the chart based on media changes\n    this.optionsProvider = Chartist.optionsProvider(this.options, this.responsiveOptions, this.eventEmitter);\n    // Register options change listener that will trigger a chart update\n    this.eventEmitter.addEventHandler('optionsChanged', function() {\n      this.update();\n    }.bind(this));\n\n    // Before the first chart creation we need to register us with all plugins that are configured\n    // Initialize all relevant plugins with our chart object and the plugin options specified in the config\n    if(this.options.plugins) {\n      this.options.plugins.forEach(function(plugin) {\n        if(plugin instanceof Array) {\n          plugin[0](this, plugin[1]);\n        } else {\n          plugin(this);\n        }\n      }.bind(this));\n    }\n\n    // Event for data transformation that allows to manipulate the data before it gets rendered in the charts\n    this.eventEmitter.emit('data', {\n      type: 'initial',\n      data: this.data\n    });\n\n    // Create the first chart\n    this.createChart(this.optionsProvider.getCurrentOptions());\n\n    // As chart is initialized from the event loop now we can reset our timeout reference\n    // This is important if the chart gets initialized on the same element twice\n    this.initializeTimeoutId = undefined;\n  }\n\n  /**\n   * Constructor of chart base class.\n   *\n   * @param query\n   * @param data\n   * @param defaultOptions\n   * @param options\n   * @param responsiveOptions\n   * @constructor\n   */\n  function Base(query, data, defaultOptions, options, responsiveOptions) {\n    this.container = Chartist.querySelector(query);\n    this.data = data;\n    this.defaultOptions = defaultOptions;\n    this.options = options;\n    this.responsiveOptions = responsiveOptions;\n    this.eventEmitter = Chartist.EventEmitter();\n    this.supportsForeignObject = Chartist.Svg.isSupported('Extensibility');\n    this.supportsAnimations = Chartist.Svg.isSupported('AnimationEventsAttribute');\n    this.resizeListener = function resizeListener(){\n      this.update();\n    }.bind(this);\n\n    if(this.container) {\n      // If chartist was already initialized in this container we are detaching all event listeners first\n      if(this.container.__chartist__) {\n        this.container.__chartist__.detach();\n      }\n\n      this.container.__chartist__ = this;\n    }\n\n    // Using event loop for first draw to make it possible to register event listeners in the same call stack where\n    // the chart was created.\n    this.initializeTimeoutId = setTimeout(initialize.bind(this), 0);\n  }\n\n  // Creating the chart base class\n  Chartist.Base = Chartist.Class.extend({\n    constructor: Base,\n    optionsProvider: undefined,\n    container: undefined,\n    svg: undefined,\n    eventEmitter: undefined,\n    createChart: function() {\n      throw new Error('Base chart type can\\'t be instantiated!');\n    },\n    update: update,\n    detach: detach,\n    on: on,\n    off: off,\n    version: Chartist.version,\n    supportsForeignObject: false\n  });\n\n}(window, document, Chartist));\n;/**\n * Chartist SVG module for simple SVG DOM abstraction\n *\n * @module Chartist.Svg\n */\n/* global Chartist */\n(function(window, document, Chartist) {\n  'use strict';\n\n  /**\n   * Chartist.Svg creates a new SVG object wrapper with a starting element. You can use the wrapper to fluently create sub-elements and modify them.\n   *\n   * @memberof Chartist.Svg\n   * @constructor\n   * @param {String|Element} name The name of the SVG element to create or an SVG dom element which should be wrapped into Chartist.Svg\n   * @param {Object} attributes An object with properties that will be added as attributes to the SVG element that is created. Attributes with undefined values will not be added.\n   * @param {String} className This class or class list will be added to the SVG element\n   * @param {Object} parent The parent SVG wrapper object where this newly created wrapper and it's element will be attached to as child\n   * @param {Boolean} insertFirst If this param is set to true in conjunction with a parent element the newly created element will be added as first child element in the parent element\n   */\n  function Svg(name, attributes, className, parent, insertFirst) {\n    // If Svg is getting called with an SVG element we just return the wrapper\n    if(name instanceof Element) {\n      this._node = name;\n    } else {\n      this._node = document.createElementNS(Chartist.namespaces.svg, name);\n\n      // If this is an SVG element created then custom namespace\n      if(name === 'svg') {\n        this.attr({\n          'xmlns:ct': Chartist.namespaces.ct\n        });\n      }\n    }\n\n    if(attributes) {\n      this.attr(attributes);\n    }\n\n    if(className) {\n      this.addClass(className);\n    }\n\n    if(parent) {\n      if (insertFirst && parent._node.firstChild) {\n        parent._node.insertBefore(this._node, parent._node.firstChild);\n      } else {\n        parent._node.appendChild(this._node);\n      }\n    }\n  }\n\n  /**\n   * Set attributes on the current SVG element of the wrapper you're currently working on.\n   *\n   * @memberof Chartist.Svg\n   * @param {Object|String} attributes An object with properties that will be added as attributes to the SVG element that is created. Attributes with undefined values will not be added. If this parameter is a String then the function is used as a getter and will return the attribute value.\n   * @param {String} ns If specified, the attribute will be obtained using getAttributeNs. In order to write namepsaced attributes you can use the namespace:attribute notation within the attributes object.\n   * @return {Object|String} The current wrapper object will be returned so it can be used for chaining or the attribute value if used as getter function.\n   */\n  function attr(attributes, ns) {\n    if(typeof attributes === 'string') {\n      if(ns) {\n        return this._node.getAttributeNS(ns, attributes);\n      } else {\n        return this._node.getAttribute(attributes);\n      }\n    }\n\n    Object.keys(attributes).forEach(function(key) {\n      // If the attribute value is undefined we can skip this one\n      if(attributes[key] === undefined) {\n        return;\n      }\n\n      if (key.indexOf(':') !== -1) {\n        var namespacedAttribute = key.split(':');\n        this._node.setAttributeNS(Chartist.namespaces[namespacedAttribute[0]], key, attributes[key]);\n      } else {\n        this._node.setAttribute(key, attributes[key]);\n      }\n    }.bind(this));\n\n    return this;\n  }\n\n  /**\n   * Create a new SVG element whose wrapper object will be selected for further operations. This way you can also create nested groups easily.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} name The name of the SVG element that should be created as child element of the currently selected element wrapper\n   * @param {Object} [attributes] An object with properties that will be added as attributes to the SVG element that is created. Attributes with undefined values will not be added.\n   * @param {String} [className] This class or class list will be added to the SVG element\n   * @param {Boolean} [insertFirst] If this param is set to true in conjunction with a parent element the newly created element will be added as first child element in the parent element\n   * @return {Chartist.Svg} Returns a Chartist.Svg wrapper object that can be used to modify the containing SVG data\n   */\n  function elem(name, attributes, className, insertFirst) {\n    return new Chartist.Svg(name, attributes, className, this, insertFirst);\n  }\n\n  /**\n   * Returns the parent Chartist.SVG wrapper object\n   *\n   * @memberof Chartist.Svg\n   * @return {Chartist.Svg} Returns a Chartist.Svg wrapper around the parent node of the current node. If the parent node is not existing or it's not an SVG node then this function will return null.\n   */\n  function parent() {\n    return this._node.parentNode instanceof SVGElement ? new Chartist.Svg(this._node.parentNode) : null;\n  }\n\n  /**\n   * This method returns a Chartist.Svg wrapper around the root SVG element of the current tree.\n   *\n   * @memberof Chartist.Svg\n   * @return {Chartist.Svg} The root SVG element wrapped in a Chartist.Svg element\n   */\n  function root() {\n    var node = this._node;\n    while(node.nodeName !== 'svg') {\n      node = node.parentNode;\n    }\n    return new Chartist.Svg(node);\n  }\n\n  /**\n   * Find the first child SVG element of the current element that matches a CSS selector. The returned object is a Chartist.Svg wrapper.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} selector A CSS selector that is used to query for child SVG elements\n   * @return {Chartist.Svg} The SVG wrapper for the element found or null if no element was found\n   */\n  function querySelector(selector) {\n    var foundNode = this._node.querySelector(selector);\n    return foundNode ? new Chartist.Svg(foundNode) : null;\n  }\n\n  /**\n   * Find the all child SVG elements of the current element that match a CSS selector. The returned object is a Chartist.Svg.List wrapper.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} selector A CSS selector that is used to query for child SVG elements\n   * @return {Chartist.Svg.List} The SVG wrapper list for the element found or null if no element was found\n   */\n  function querySelectorAll(selector) {\n    var foundNodes = this._node.querySelectorAll(selector);\n    return foundNodes.length ? new Chartist.Svg.List(foundNodes) : null;\n  }\n\n  /**\n   * This method creates a foreignObject (see https://developer.mozilla.org/en-US/docs/Web/SVG/Element/foreignObject) that allows to embed HTML content into a SVG graphic. With the help of foreignObjects you can enable the usage of regular HTML elements inside of SVG where they are subject for SVG positioning and transformation but the Browser will use the HTML rendering capabilities for the containing DOM.\n   *\n   * @memberof Chartist.Svg\n   * @param {Node|String} content The DOM Node, or HTML string that will be converted to a DOM Node, that is then placed into and wrapped by the foreignObject\n   * @param {String} [attributes] An object with properties that will be added as attributes to the foreignObject element that is created. Attributes with undefined values will not be added.\n   * @param {String} [className] This class or class list will be added to the SVG element\n   * @param {Boolean} [insertFirst] Specifies if the foreignObject should be inserted as first child\n   * @return {Chartist.Svg} New wrapper object that wraps the foreignObject element\n   */\n  function foreignObject(content, attributes, className, insertFirst) {\n    // If content is string then we convert it to DOM\n    // TODO: Handle case where content is not a string nor a DOM Node\n    if(typeof content === 'string') {\n      var container = document.createElement('div');\n      container.innerHTML = content;\n      content = container.firstChild;\n    }\n\n    // Adding namespace to content element\n    content.setAttribute('xmlns', Chartist.namespaces.xmlns);\n\n    // Creating the foreignObject without required extension attribute (as described here\n    // http://www.w3.org/TR/SVG/extend.html#ForeignObjectElement)\n    var fnObj = this.elem('foreignObject', attributes, className, insertFirst);\n\n    // Add content to foreignObjectElement\n    fnObj._node.appendChild(content);\n\n    return fnObj;\n  }\n\n  /**\n   * This method adds a new text element to the current Chartist.Svg wrapper.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} t The text that should be added to the text element that is created\n   * @return {Chartist.Svg} The same wrapper object that was used to add the newly created element\n   */\n  function text(t) {\n    this._node.appendChild(document.createTextNode(t));\n    return this;\n  }\n\n  /**\n   * This method will clear all child nodes of the current wrapper object.\n   *\n   * @memberof Chartist.Svg\n   * @return {Chartist.Svg} The same wrapper object that got emptied\n   */\n  function empty() {\n    while (this._node.firstChild) {\n      this._node.removeChild(this._node.firstChild);\n    }\n\n    return this;\n  }\n\n  /**\n   * This method will cause the current wrapper to remove itself from its parent wrapper. Use this method if you'd like to get rid of an element in a given DOM structure.\n   *\n   * @memberof Chartist.Svg\n   * @return {Chartist.Svg} The parent wrapper object of the element that got removed\n   */\n  function remove() {\n    this._node.parentNode.removeChild(this._node);\n    return this.parent();\n  }\n\n  /**\n   * This method will replace the element with a new element that can be created outside of the current DOM.\n   *\n   * @memberof Chartist.Svg\n   * @param {Chartist.Svg} newElement The new Chartist.Svg object that will be used to replace the current wrapper object\n   * @return {Chartist.Svg} The wrapper of the new element\n   */\n  function replace(newElement) {\n    this._node.parentNode.replaceChild(newElement._node, this._node);\n    return newElement;\n  }\n\n  /**\n   * This method will append an element to the current element as a child.\n   *\n   * @memberof Chartist.Svg\n   * @param {Chartist.Svg} element The Chartist.Svg element that should be added as a child\n   * @param {Boolean} [insertFirst] Specifies if the element should be inserted as first child\n   * @return {Chartist.Svg} The wrapper of the appended object\n   */\n  function append(element, insertFirst) {\n    if(insertFirst && this._node.firstChild) {\n      this._node.insertBefore(element._node, this._node.firstChild);\n    } else {\n      this._node.appendChild(element._node);\n    }\n\n    return this;\n  }\n\n  /**\n   * Returns an array of class names that are attached to the current wrapper element. This method can not be chained further.\n   *\n   * @memberof Chartist.Svg\n   * @return {Array} A list of classes or an empty array if there are no classes on the current element\n   */\n  function classes() {\n    return this._node.getAttribute('class') ? this._node.getAttribute('class').trim().split(/\\s+/) : [];\n  }\n\n  /**\n   * Adds one or a space separated list of classes to the current element and ensures the classes are only existing once.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} names A white space separated list of class names\n   * @return {Chartist.Svg} The wrapper of the current element\n   */\n  function addClass(names) {\n    this._node.setAttribute('class',\n      this.classes(this._node)\n        .concat(names.trim().split(/\\s+/))\n        .filter(function(elem, pos, self) {\n          return self.indexOf(elem) === pos;\n        }).join(' ')\n    );\n\n    return this;\n  }\n\n  /**\n   * Removes one or a space separated list of classes from the current element.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} names A white space separated list of class names\n   * @return {Chartist.Svg} The wrapper of the current element\n   */\n  function removeClass(names) {\n    var removedClasses = names.trim().split(/\\s+/);\n\n    this._node.setAttribute('class', this.classes(this._node).filter(function(name) {\n      return removedClasses.indexOf(name) === -1;\n    }).join(' '));\n\n    return this;\n  }\n\n  /**\n   * Removes all classes from the current element.\n   *\n   * @memberof Chartist.Svg\n   * @return {Chartist.Svg} The wrapper of the current element\n   */\n  function removeAllClasses() {\n    this._node.setAttribute('class', '');\n\n    return this;\n  }\n\n  /**\n   * Get element height using `getBoundingClientRect`\n   *\n   * @memberof Chartist.Svg\n   * @return {Number} The elements height in pixels\n   */\n  function height() {\n    return this._node.getBoundingClientRect().height;\n  }\n\n  /**\n   * Get element width using `getBoundingClientRect`\n   *\n   * @memberof Chartist.Core\n   * @return {Number} The elements width in pixels\n   */\n  function width() {\n    return this._node.getBoundingClientRect().width;\n  }\n\n  /**\n   * The animate function lets you animate the current element with SMIL animations. You can add animations for multiple attributes at the same time by using an animation definition object. This object should contain SMIL animation attributes. Please refer to http://www.w3.org/TR/SVG/animate.html for a detailed specification about the available animation attributes. Additionally an easing property can be passed in the animation definition object. This can be a string with a name of an easing function in `Chartist.Svg.Easing` or an array with four numbers specifying a cubic Bézier curve.\n   * **An animations object could look like this:**\n   * ```javascript\n   * element.animate({\n   *   opacity: {\n   *     dur: 1000,\n   *     from: 0,\n   *     to: 1\n   *   },\n   *   x1: {\n   *     dur: '1000ms',\n   *     from: 100,\n   *     to: 200,\n   *     easing: 'easeOutQuart'\n   *   },\n   *   y1: {\n   *     dur: '2s',\n   *     from: 0,\n   *     to: 100\n   *   }\n   * });\n   * ```\n   * **Automatic unit conversion**\n   * For the `dur` and the `begin` animate attribute you can also omit a unit by passing a number. The number will automatically be converted to milli seconds.\n   * **Guided mode**\n   * The default behavior of SMIL animations with offset using the `begin` attribute is that the attribute will keep it's original value until the animation starts. Mostly this behavior is not desired as you'd like to have your element attributes already initialized with the animation `from` value even before the animation starts. Also if you don't specify `fill=\"freeze\"` on an animate element or if you delete the animation after it's done (which is done in guided mode) the attribute will switch back to the initial value. This behavior is also not desired when performing simple one-time animations. For one-time animations you'd want to trigger animations immediately instead of relative to the document begin time. That's why in guided mode Chartist.Svg will also use the `begin` property to schedule a timeout and manually start the animation after the timeout. If you're using multiple SMIL definition objects for an attribute (in an array), guided mode will be disabled for this attribute, even if you explicitly enabled it.\n   * If guided mode is enabled the following behavior is added:\n   * - Before the animation starts (even when delayed with `begin`) the animated attribute will be set already to the `from` value of the animation\n   * - `begin` is explicitly set to `indefinite` so it can be started manually without relying on document begin time (creation)\n   * - The animate element will be forced to use `fill=\"freeze\"`\n   * - The animation will be triggered with `beginElement()` in a timeout where `begin` of the definition object is interpreted in milli seconds. If no `begin` was specified the timeout is triggered immediately.\n   * - After the animation the element attribute value will be set to the `to` value of the animation\n   * - The animate element is deleted from the DOM\n   *\n   * @memberof Chartist.Svg\n   * @param {Object} animations An animations object where the property keys are the attributes you'd like to animate. The properties should be objects again that contain the SMIL animation attributes (usually begin, dur, from, and to). The property begin and dur is auto converted (see Automatic unit conversion). You can also schedule multiple animations for the same attribute by passing an Array of SMIL definition objects. Attributes that contain an array of SMIL definition objects will not be executed in guided mode.\n   * @param {Boolean} guided Specify if guided mode should be activated for this animation (see Guided mode). If not otherwise specified, guided mode will be activated.\n   * @param {Object} eventEmitter If specified, this event emitter will be notified when an animation starts or ends.\n   * @return {Chartist.Svg} The current element where the animation was added\n   */\n  function animate(animations, guided, eventEmitter) {\n    if(guided === undefined) {\n      guided = true;\n    }\n\n    Object.keys(animations).forEach(function createAnimateForAttributes(attribute) {\n\n      function createAnimate(animationDefinition, guided) {\n        var attributeProperties = {},\n          animate,\n          timeout,\n          easing;\n\n        // Check if an easing is specified in the definition object and delete it from the object as it will not\n        // be part of the animate element attributes.\n        if(animationDefinition.easing) {\n          // If already an easing Bézier curve array we take it or we lookup a easing array in the Easing object\n          easing = animationDefinition.easing instanceof Array ?\n            animationDefinition.easing :\n            Chartist.Svg.Easing[animationDefinition.easing];\n          delete animationDefinition.easing;\n        }\n\n        // If numeric dur or begin was provided we assume milli seconds\n        animationDefinition.begin = Chartist.ensureUnit(animationDefinition.begin, 'ms');\n        animationDefinition.dur = Chartist.ensureUnit(animationDefinition.dur, 'ms');\n\n        if(easing) {\n          animationDefinition.calcMode = 'spline';\n          animationDefinition.keySplines = easing.join(' ');\n          animationDefinition.keyTimes = '0;1';\n        }\n\n        // Adding \"fill: freeze\" if we are in guided mode and set initial attribute values\n        if(guided) {\n          animationDefinition.fill = 'freeze';\n          // Animated property on our element should already be set to the animation from value in guided mode\n          attributeProperties[attribute] = animationDefinition.from;\n          this.attr(attributeProperties);\n\n          // In guided mode we also set begin to indefinite so we can trigger the start manually and put the begin\n          // which needs to be in ms aside\n          timeout = Chartist.quantity(animationDefinition.begin || 0).value;\n          animationDefinition.begin = 'indefinite';\n        }\n\n        animate = this.elem('animate', Chartist.extend({\n          attributeName: attribute\n        }, animationDefinition));\n\n        if(guided) {\n          // If guided we take the value that was put aside in timeout and trigger the animation manually with a timeout\n          setTimeout(function() {\n            // If beginElement fails we set the animated attribute to the end position and remove the animate element\n            // This happens if the SMIL ElementTimeControl interface is not supported or any other problems occured in\n            // the browser. (Currently FF 34 does not support animate elements in foreignObjects)\n            try {\n              animate._node.beginElement();\n            } catch(err) {\n              // Set animated attribute to current animated value\n              attributeProperties[attribute] = animationDefinition.to;\n              this.attr(attributeProperties);\n              // Remove the animate element as it's no longer required\n              animate.remove();\n            }\n          }.bind(this), timeout);\n        }\n\n        if(eventEmitter) {\n          animate._node.addEventListener('beginEvent', function handleBeginEvent() {\n            eventEmitter.emit('animationBegin', {\n              element: this,\n              animate: animate._node,\n              params: animationDefinition\n            });\n          }.bind(this));\n        }\n\n        animate._node.addEventListener('endEvent', function handleEndEvent() {\n          if(eventEmitter) {\n            eventEmitter.emit('animationEnd', {\n              element: this,\n              animate: animate._node,\n              params: animationDefinition\n            });\n          }\n\n          if(guided) {\n            // Set animated attribute to current animated value\n            attributeProperties[attribute] = animationDefinition.to;\n            this.attr(attributeProperties);\n            // Remove the animate element as it's no longer required\n            animate.remove();\n          }\n        }.bind(this));\n      }\n\n      // If current attribute is an array of definition objects we create an animate for each and disable guided mode\n      if(animations[attribute] instanceof Array) {\n        animations[attribute].forEach(function(animationDefinition) {\n          createAnimate.bind(this)(animationDefinition, false);\n        }.bind(this));\n      } else {\n        createAnimate.bind(this)(animations[attribute], guided);\n      }\n\n    }.bind(this));\n\n    return this;\n  }\n\n  Chartist.Svg = Chartist.Class.extend({\n    constructor: Svg,\n    attr: attr,\n    elem: elem,\n    parent: parent,\n    root: root,\n    querySelector: querySelector,\n    querySelectorAll: querySelectorAll,\n    foreignObject: foreignObject,\n    text: text,\n    empty: empty,\n    remove: remove,\n    replace: replace,\n    append: append,\n    classes: classes,\n    addClass: addClass,\n    removeClass: removeClass,\n    removeAllClasses: removeAllClasses,\n    height: height,\n    width: width,\n    animate: animate\n  });\n\n  /**\n   * This method checks for support of a given SVG feature like Extensibility, SVG-animation or the like. Check http://www.w3.org/TR/SVG11/feature for a detailed list.\n   *\n   * @memberof Chartist.Svg\n   * @param {String} feature The SVG 1.1 feature that should be checked for support.\n   * @return {Boolean} True of false if the feature is supported or not\n   */\n  Chartist.Svg.isSupported = function(feature) {\n    return document.implementation.hasFeature('http://www.w3.org/TR/SVG11/feature#' + feature, '1.1');\n  };\n\n  /**\n   * This Object contains some standard easing cubic bezier curves. Then can be used with their name in the `Chartist.Svg.animate`. You can also extend the list and use your own name in the `animate` function. Click the show code button to see the available bezier functions.\n   *\n   * @memberof Chartist.Svg\n   */\n  var easingCubicBeziers = {\n    easeInSine: [0.47, 0, 0.745, 0.715],\n    easeOutSine: [0.39, 0.575, 0.565, 1],\n    easeInOutSine: [0.445, 0.05, 0.55, 0.95],\n    easeInQuad: [0.55, 0.085, 0.68, 0.53],\n    easeOutQuad: [0.25, 0.46, 0.45, 0.94],\n    easeInOutQuad: [0.455, 0.03, 0.515, 0.955],\n    easeInCubic: [0.55, 0.055, 0.675, 0.19],\n    easeOutCubic: [0.215, 0.61, 0.355, 1],\n    easeInOutCubic: [0.645, 0.045, 0.355, 1],\n    easeInQuart: [0.895, 0.03, 0.685, 0.22],\n    easeOutQuart: [0.165, 0.84, 0.44, 1],\n    easeInOutQuart: [0.77, 0, 0.175, 1],\n    easeInQuint: [0.755, 0.05, 0.855, 0.06],\n    easeOutQuint: [0.23, 1, 0.32, 1],\n    easeInOutQuint: [0.86, 0, 0.07, 1],\n    easeInExpo: [0.95, 0.05, 0.795, 0.035],\n    easeOutExpo: [0.19, 1, 0.22, 1],\n    easeInOutExpo: [1, 0, 0, 1],\n    easeInCirc: [0.6, 0.04, 0.98, 0.335],\n    easeOutCirc: [0.075, 0.82, 0.165, 1],\n    easeInOutCirc: [0.785, 0.135, 0.15, 0.86],\n    easeInBack: [0.6, -0.28, 0.735, 0.045],\n    easeOutBack: [0.175, 0.885, 0.32, 1.275],\n    easeInOutBack: [0.68, -0.55, 0.265, 1.55]\n  };\n\n  Chartist.Svg.Easing = easingCubicBeziers;\n\n  /**\n   * This helper class is to wrap multiple `Chartist.Svg` elements into a list where you can call the `Chartist.Svg` functions on all elements in the list with one call. This is helpful when you'd like to perform calls with `Chartist.Svg` on multiple elements.\n   * An instance of this class is also returned by `Chartist.Svg.querySelectorAll`.\n   *\n   * @memberof Chartist.Svg\n   * @param {Array<Node>|NodeList} nodeList An Array of SVG DOM nodes or a SVG DOM NodeList (as returned by document.querySelectorAll)\n   * @constructor\n   */\n  function SvgList(nodeList) {\n    var list = this;\n\n    this.svgElements = [];\n    for(var i = 0; i < nodeList.length; i++) {\n      this.svgElements.push(new Chartist.Svg(nodeList[i]));\n    }\n\n    // Add delegation methods for Chartist.Svg\n    Object.keys(Chartist.Svg.prototype).filter(function(prototypeProperty) {\n      return ['constructor',\n          'parent',\n          'querySelector',\n          'querySelectorAll',\n          'replace',\n          'append',\n          'classes',\n          'height',\n          'width'].indexOf(prototypeProperty) === -1;\n    }).forEach(function(prototypeProperty) {\n      list[prototypeProperty] = function() {\n        var args = Array.prototype.slice.call(arguments, 0);\n        list.svgElements.forEach(function(element) {\n          Chartist.Svg.prototype[prototypeProperty].apply(element, args);\n        });\n        return list;\n      };\n    });\n  }\n\n  Chartist.Svg.List = Chartist.Class.extend({\n    constructor: SvgList\n  });\n}(window, document, Chartist));\n;/**\n * Chartist SVG path module for SVG path description creation and modification.\n *\n * @module Chartist.Svg.Path\n */\n/* global Chartist */\n(function(window, document, Chartist) {\n  'use strict';\n\n  /**\n   * Contains the descriptors of supported element types in a SVG path. Currently only move, line and curve are supported.\n   *\n   * @memberof Chartist.Svg.Path\n   * @type {Object}\n   */\n  var elementDescriptions = {\n    m: ['x', 'y'],\n    l: ['x', 'y'],\n    c: ['x1', 'y1', 'x2', 'y2', 'x', 'y'],\n    a: ['rx', 'ry', 'xAr', 'lAf', 'sf', 'x', 'y']\n  };\n\n  /**\n   * Default options for newly created SVG path objects.\n   *\n   * @memberof Chartist.Svg.Path\n   * @type {Object}\n   */\n  var defaultOptions = {\n    // The accuracy in digit count after the decimal point. This will be used to round numbers in the SVG path. If this option is set to false then no rounding will be performed.\n    accuracy: 3\n  };\n\n  function element(command, params, pathElements, pos, relative, data) {\n    var pathElement = Chartist.extend({\n      command: relative ? command.toLowerCase() : command.toUpperCase()\n    }, params, data ? { data: data } : {} );\n\n    pathElements.splice(pos, 0, pathElement);\n  }\n\n  function forEachParam(pathElements, cb) {\n    pathElements.forEach(function(pathElement, pathElementIndex) {\n      elementDescriptions[pathElement.command.toLowerCase()].forEach(function(paramName, paramIndex) {\n        cb(pathElement, paramName, pathElementIndex, paramIndex, pathElements);\n      });\n    });\n  }\n\n  /**\n   * Used to construct a new path object.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Boolean} close If set to true then this path will be closed when stringified (with a Z at the end)\n   * @param {Object} options Options object that overrides the default objects. See default options for more details.\n   * @constructor\n   */\n  function SvgPath(close, options) {\n    this.pathElements = [];\n    this.pos = 0;\n    this.close = close;\n    this.options = Chartist.extend({}, defaultOptions, options);\n  }\n\n  /**\n   * Gets or sets the current position (cursor) inside of the path. You can move around the cursor freely but limited to 0 or the count of existing elements. All modifications with element functions will insert new elements at the position of this cursor.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} [pos] If a number is passed then the cursor is set to this position in the path element array.\n   * @return {Chartist.Svg.Path|Number} If the position parameter was passed then the return value will be the path object for easy call chaining. If no position parameter was passed then the current position is returned.\n   */\n  function position(pos) {\n    if(pos !== undefined) {\n      this.pos = Math.max(0, Math.min(this.pathElements.length, pos));\n      return this;\n    } else {\n      return this.pos;\n    }\n  }\n\n  /**\n   * Removes elements from the path starting at the current position.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} count Number of path elements that should be removed from the current position.\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function remove(count) {\n    this.pathElements.splice(this.pos, count);\n    return this;\n  }\n\n  /**\n   * Use this function to add a new move SVG path element.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} x The x coordinate for the move element.\n   * @param {Number} y The y coordinate for the move element.\n   * @param {Boolean} [relative] If set to true the move element will be created with relative coordinates (lowercase letter)\n   * @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function move(x, y, relative, data) {\n    element('M', {\n      x: +x,\n      y: +y\n    }, this.pathElements, this.pos++, relative, data);\n    return this;\n  }\n\n  /**\n   * Use this function to add a new line SVG path element.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} x The x coordinate for the line element.\n   * @param {Number} y The y coordinate for the line element.\n   * @param {Boolean} [relative] If set to true the line element will be created with relative coordinates (lowercase letter)\n   * @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function line(x, y, relative, data) {\n    element('L', {\n      x: +x,\n      y: +y\n    }, this.pathElements, this.pos++, relative, data);\n    return this;\n  }\n\n  /**\n   * Use this function to add a new curve SVG path element.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} x1 The x coordinate for the first control point of the bezier curve.\n   * @param {Number} y1 The y coordinate for the first control point of the bezier curve.\n   * @param {Number} x2 The x coordinate for the second control point of the bezier curve.\n   * @param {Number} y2 The y coordinate for the second control point of the bezier curve.\n   * @param {Number} x The x coordinate for the target point of the curve element.\n   * @param {Number} y The y coordinate for the target point of the curve element.\n   * @param {Boolean} [relative] If set to true the curve element will be created with relative coordinates (lowercase letter)\n   * @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function curve(x1, y1, x2, y2, x, y, relative, data) {\n    element('C', {\n      x1: +x1,\n      y1: +y1,\n      x2: +x2,\n      y2: +y2,\n      x: +x,\n      y: +y\n    }, this.pathElements, this.pos++, relative, data);\n    return this;\n  }\n\n  /**\n   * Use this function to add a new non-bezier curve SVG path element.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} rx The radius to be used for the x-axis of the arc.\n   * @param {Number} ry The radius to be used for the y-axis of the arc.\n   * @param {Number} xAr Defines the orientation of the arc\n   * @param {Number} lAf Large arc flag\n   * @param {Number} sf Sweep flag\n   * @param {Number} x The x coordinate for the target point of the curve element.\n   * @param {Number} y The y coordinate for the target point of the curve element.\n   * @param {Boolean} [relative] If set to true the curve element will be created with relative coordinates (lowercase letter)\n   * @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function arc(rx, ry, xAr, lAf, sf, x, y, relative, data) {\n    element('A', {\n      rx: +rx,\n      ry: +ry,\n      xAr: +xAr,\n      lAf: +lAf,\n      sf: +sf,\n      x: +x,\n      y: +y\n    }, this.pathElements, this.pos++, relative, data);\n    return this;\n  }\n\n  /**\n   * Parses an SVG path seen in the d attribute of path elements, and inserts the parsed elements into the existing path object at the current cursor position. Any closing path indicators (Z at the end of the path) will be ignored by the parser as this is provided by the close option in the options of the path object.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {String} path Any SVG path that contains move (m), line (l) or curve (c) components.\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function parse(path) {\n    // Parsing the SVG path string into an array of arrays [['M', '10', '10'], ['L', '100', '100']]\n    var chunks = path.replace(/([A-Za-z])([0-9])/g, '$1 $2')\n      .replace(/([0-9])([A-Za-z])/g, '$1 $2')\n      .split(/[\\s,]+/)\n      .reduce(function(result, element) {\n        if(element.match(/[A-Za-z]/)) {\n          result.push([]);\n        }\n\n        result[result.length - 1].push(element);\n        return result;\n      }, []);\n\n    // If this is a closed path we remove the Z at the end because this is determined by the close option\n    if(chunks[chunks.length - 1][0].toUpperCase() === 'Z') {\n      chunks.pop();\n    }\n\n    // Using svgPathElementDescriptions to map raw path arrays into objects that contain the command and the parameters\n    // For example {command: 'M', x: '10', y: '10'}\n    var elements = chunks.map(function(chunk) {\n        var command = chunk.shift(),\n          description = elementDescriptions[command.toLowerCase()];\n\n        return Chartist.extend({\n          command: command\n        }, description.reduce(function(result, paramName, index) {\n          result[paramName] = +chunk[index];\n          return result;\n        }, {}));\n      });\n\n    // Preparing a splice call with the elements array as var arg params and insert the parsed elements at the current position\n    var spliceArgs = [this.pos, 0];\n    Array.prototype.push.apply(spliceArgs, elements);\n    Array.prototype.splice.apply(this.pathElements, spliceArgs);\n    // Increase the internal position by the element count\n    this.pos += elements.length;\n\n    return this;\n  }\n\n  /**\n   * This function renders to current SVG path object into a final SVG string that can be used in the d attribute of SVG path elements. It uses the accuracy option to round big decimals. If the close parameter was set in the constructor of this path object then a path closing Z will be appended to the output string.\n   *\n   * @memberof Chartist.Svg.Path\n   * @return {String}\n   */\n  function stringify() {\n    var accuracyMultiplier = Math.pow(10, this.options.accuracy);\n\n    return this.pathElements.reduce(function(path, pathElement) {\n        var params = elementDescriptions[pathElement.command.toLowerCase()].map(function(paramName) {\n          return this.options.accuracy ?\n            (Math.round(pathElement[paramName] * accuracyMultiplier) / accuracyMultiplier) :\n            pathElement[paramName];\n        }.bind(this));\n\n        return path + pathElement.command + params.join(',');\n      }.bind(this), '') + (this.close ? 'Z' : '');\n  }\n\n  /**\n   * Scales all elements in the current SVG path object. There is an individual parameter for each coordinate. Scaling will also be done for control points of curves, affecting the given coordinate.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} x The number which will be used to scale the x, x1 and x2 of all path elements.\n   * @param {Number} y The number which will be used to scale the y, y1 and y2 of all path elements.\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function scale(x, y) {\n    forEachParam(this.pathElements, function(pathElement, paramName) {\n      pathElement[paramName] *= paramName[0] === 'x' ? x : y;\n    });\n    return this;\n  }\n\n  /**\n   * Translates all elements in the current SVG path object. The translation is relative and there is an individual parameter for each coordinate. Translation will also be done for control points of curves, affecting the given coordinate.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Number} x The number which will be used to translate the x, x1 and x2 of all path elements.\n   * @param {Number} y The number which will be used to translate the y, y1 and y2 of all path elements.\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function translate(x, y) {\n    forEachParam(this.pathElements, function(pathElement, paramName) {\n      pathElement[paramName] += paramName[0] === 'x' ? x : y;\n    });\n    return this;\n  }\n\n  /**\n   * This function will run over all existing path elements and then loop over their attributes. The callback function will be called for every path element attribute that exists in the current path.\n   * The method signature of the callback function looks like this:\n   * ```javascript\n   * function(pathElement, paramName, pathElementIndex, paramIndex, pathElements)\n   * ```\n   * If something else than undefined is returned by the callback function, this value will be used to replace the old value. This allows you to build custom transformations of path objects that can't be achieved using the basic transformation functions scale and translate.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Function} transformFnc The callback function for the transformation. Check the signature in the function description.\n   * @return {Chartist.Svg.Path} The current path object for easy call chaining.\n   */\n  function transform(transformFnc) {\n    forEachParam(this.pathElements, function(pathElement, paramName, pathElementIndex, paramIndex, pathElements) {\n      var transformed = transformFnc(pathElement, paramName, pathElementIndex, paramIndex, pathElements);\n      if(transformed || transformed === 0) {\n        pathElement[paramName] = transformed;\n      }\n    });\n    return this;\n  }\n\n  /**\n   * This function clones a whole path object with all its properties. This is a deep clone and path element objects will also be cloned.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Boolean} [close] Optional option to set the new cloned path to closed. If not specified or false, the original path close option will be used.\n   * @return {Chartist.Svg.Path}\n   */\n  function clone(close) {\n    var c = new Chartist.Svg.Path(close || this.close);\n    c.pos = this.pos;\n    c.pathElements = this.pathElements.slice().map(function cloneElements(pathElement) {\n      return Chartist.extend({}, pathElement);\n    });\n    c.options = Chartist.extend({}, this.options);\n    return c;\n  }\n\n  /**\n   * Split a Svg.Path object by a specific command in the path chain. The path chain will be split and an array of newly created paths objects will be returned. This is useful if you'd like to split an SVG path by it's move commands, for example, in order to isolate chunks of drawings.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {String} command The command you'd like to use to split the path\n   * @return {Array<Chartist.Svg.Path>}\n   */\n  function splitByCommand(command) {\n    var split = [\n      new Chartist.Svg.Path()\n    ];\n\n    this.pathElements.forEach(function(pathElement) {\n      if(pathElement.command === command.toUpperCase() && split[split.length - 1].pathElements.length !== 0) {\n        split.push(new Chartist.Svg.Path());\n      }\n\n      split[split.length - 1].pathElements.push(pathElement);\n    });\n\n    return split;\n  }\n\n  /**\n   * This static function on `Chartist.Svg.Path` is joining multiple paths together into one paths.\n   *\n   * @memberof Chartist.Svg.Path\n   * @param {Array<Chartist.Svg.Path>} paths A list of paths to be joined together. The order is important.\n   * @param {boolean} close If the newly created path should be a closed path\n   * @param {Object} options Path options for the newly created path.\n   * @return {Chartist.Svg.Path}\n   */\n\n  function join(paths, close, options) {\n    var joinedPath = new Chartist.Svg.Path(close, options);\n    for(var i = 0; i < paths.length; i++) {\n      var path = paths[i];\n      for(var j = 0; j < path.pathElements.length; j++) {\n        joinedPath.pathElements.push(path.pathElements[j]);\n      }\n    }\n    return joinedPath;\n  }\n\n  Chartist.Svg.Path = Chartist.Class.extend({\n    constructor: SvgPath,\n    position: position,\n    remove: remove,\n    move: move,\n    line: line,\n    curve: curve,\n    arc: arc,\n    scale: scale,\n    translate: translate,\n    transform: transform,\n    parse: parse,\n    stringify: stringify,\n    clone: clone,\n    splitByCommand: splitByCommand\n  });\n\n  Chartist.Svg.Path.elementDescriptions = elementDescriptions;\n  Chartist.Svg.Path.join = join;\n}(window, document, Chartist));\n;/* global Chartist */\n(function (window, document, Chartist) {\n  'use strict';\n\n  var axisUnits = {\n    x: {\n      pos: 'x',\n      len: 'width',\n      dir: 'horizontal',\n      rectStart: 'x1',\n      rectEnd: 'x2',\n      rectOffset: 'y2'\n    },\n    y: {\n      pos: 'y',\n      len: 'height',\n      dir: 'vertical',\n      rectStart: 'y2',\n      rectEnd: 'y1',\n      rectOffset: 'x1'\n    }\n  };\n\n  function Axis(units, chartRect, ticks, options) {\n    this.units = units;\n    this.counterUnits = units === axisUnits.x ? axisUnits.y : axisUnits.x;\n    this.chartRect = chartRect;\n    this.axisLength = chartRect[units.rectEnd] - chartRect[units.rectStart];\n    this.gridOffset = chartRect[units.rectOffset];\n    this.ticks = ticks;\n    this.options = options;\n  }\n\n  function createGridAndLabels(gridGroup, labelGroup, useForeignObject, chartOptions, eventEmitter) {\n    var axisOptions = chartOptions['axis' + this.units.pos.toUpperCase()];\n    var projectedValues = this.ticks.map(this.projectValue.bind(this));\n    var labelValues = this.ticks.map(axisOptions.labelInterpolationFnc);\n\n    projectedValues.forEach(function(projectedValue, index) {\n      var labelOffset = {\n        x: 0,\n        y: 0\n      };\n\n      // TODO: Find better solution for solving this problem\n      // Calculate how much space we have available for the label\n      var labelLength;\n      if(projectedValues[index + 1]) {\n        // If we still have one label ahead, we can calculate the distance to the next tick / label\n        labelLength = projectedValues[index + 1] - projectedValue;\n      } else {\n        // If we don't have a label ahead and we have only two labels in total, we just take the remaining distance to\n        // on the whole axis length. We limit that to a minimum of 30 pixel, so that labels close to the border will\n        // still be visible inside of the chart padding.\n        labelLength = Math.max(this.axisLength - projectedValue, 30);\n      }\n\n      // Skip grid lines and labels where interpolated label values are falsey (execpt for 0)\n      if(Chartist.isFalseyButZero(labelValues[index]) && labelValues[index] !== '') {\n        return;\n      }\n\n      // Transform to global coordinates using the chartRect\n      // We also need to set the label offset for the createLabel function\n      if(this.units.pos === 'x') {\n        projectedValue = this.chartRect.x1 + projectedValue;\n        labelOffset.x = chartOptions.axisX.labelOffset.x;\n\n        // If the labels should be positioned in start position (top side for vertical axis) we need to set a\n        // different offset as for positioned with end (bottom)\n        if(chartOptions.axisX.position === 'start') {\n          labelOffset.y = this.chartRect.padding.top + chartOptions.axisX.labelOffset.y + (useForeignObject ? 5 : 20);\n        } else {\n          labelOffset.y = this.chartRect.y1 + chartOptions.axisX.labelOffset.y + (useForeignObject ? 5 : 20);\n        }\n      } else {\n        projectedValue = this.chartRect.y1 - projectedValue;\n        labelOffset.y = chartOptions.axisY.labelOffset.y - (useForeignObject ? labelLength : 0);\n\n        // If the labels should be positioned in start position (left side for horizontal axis) we need to set a\n        // different offset as for positioned with end (right side)\n        if(chartOptions.axisY.position === 'start') {\n          labelOffset.x = useForeignObject ? this.chartRect.padding.left + chartOptions.axisY.labelOffset.x : this.chartRect.x1 - 10;\n        } else {\n          labelOffset.x = this.chartRect.x2 + chartOptions.axisY.labelOffset.x + 10;\n        }\n      }\n\n      if(axisOptions.showGrid) {\n        Chartist.createGrid(projectedValue, index, this, this.gridOffset, this.chartRect[this.counterUnits.len](), gridGroup, [\n          chartOptions.classNames.grid,\n          chartOptions.classNames[this.units.dir]\n        ], eventEmitter);\n      }\n\n      if(axisOptions.showLabel) {\n        Chartist.createLabel(projectedValue, labelLength, index, labelValues, this, axisOptions.offset, labelOffset, labelGroup, [\n          chartOptions.classNames.label,\n          chartOptions.classNames[this.units.dir],\n          chartOptions.classNames[axisOptions.position]\n        ], useForeignObject, eventEmitter);\n      }\n    }.bind(this));\n  }\n\n  Chartist.Axis = Chartist.Class.extend({\n    constructor: Axis,\n    createGridAndLabels: createGridAndLabels,\n    projectValue: function(value, index, data) {\n      throw new Error('Base axis can\\'t be instantiated!');\n    }\n  });\n\n  Chartist.Axis.units = axisUnits;\n\n}(window, document, Chartist));\n;/**\n * The auto scale axis uses standard linear scale projection of values along an axis. It uses order of magnitude to find a scale automatically and evaluates the available space in order to find the perfect amount of ticks for your chart.\n * **Options**\n * The following options are used by this axis in addition to the default axis options outlined in the axis configuration of the chart default settings.\n * ```javascript\n * var options = {\n *   // If high is specified then the axis will display values explicitly up to this value and the computed maximum from the data is ignored\n *   high: 100,\n *   // If low is specified then the axis will display values explicitly down to this value and the computed minimum from the data is ignored\n *   low: 0,\n *   // This option will be used when finding the right scale division settings. The amount of ticks on the scale will be determined so that as many ticks as possible will be displayed, while not violating this minimum required space (in pixel).\n *   scaleMinSpace: 20,\n *   // Can be set to true or false. If set to true, the scale will be generated with whole numbers only.\n *   onlyInteger: true,\n *   // The reference value can be used to make sure that this value will always be on the chart. This is especially useful on bipolar charts where the bipolar center always needs to be part of the chart.\n *   referenceValue: 5\n * };\n * ```\n *\n * @module Chartist.AutoScaleAxis\n */\n/* global Chartist */\n(function (window, document, Chartist) {\n  'use strict';\n\n  function AutoScaleAxis(axisUnit, data, chartRect, options) {\n    // Usually we calculate highLow based on the data but this can be overriden by a highLow object in the options\n    var highLow = options.highLow || Chartist.getHighLow(data.normalized, options, axisUnit.pos);\n    this.bounds = Chartist.getBounds(chartRect[axisUnit.rectEnd] - chartRect[axisUnit.rectStart], highLow, options.scaleMinSpace || 20, options.onlyInteger);\n    this.range = {\n      min: this.bounds.min,\n      max: this.bounds.max\n    };\n\n    Chartist.AutoScaleAxis.super.constructor.call(this,\n      axisUnit,\n      chartRect,\n      this.bounds.values,\n      options);\n  }\n\n  function projectValue(value) {\n    return this.axisLength * (+Chartist.getMultiValue(value, this.units.pos) - this.bounds.min) / this.bounds.range;\n  }\n\n  Chartist.AutoScaleAxis = Chartist.Axis.extend({\n    constructor: AutoScaleAxis,\n    projectValue: projectValue\n  });\n\n}(window, document, Chartist));\n;/**\n * The fixed scale axis uses standard linear projection of values along an axis. It makes use of a divisor option to divide the range provided from the minimum and maximum value or the options high and low that will override the computed minimum and maximum.\n * **Options**\n * The following options are used by this axis in addition to the default axis options outlined in the axis configuration of the chart default settings.\n * ```javascript\n * var options = {\n *   // If high is specified then the axis will display values explicitly up to this value and the computed maximum from the data is ignored\n *   high: 100,\n *   // If low is specified then the axis will display values explicitly down to this value and the computed minimum from the data is ignored\n *   low: 0,\n *   // If specified then the value range determined from minimum to maximum (or low and high) will be divided by this number and ticks will be generated at those division points. The default divisor is 1.\n *   divisor: 4,\n *   // If ticks is explicitly set, then the axis will not compute the ticks with the divisor, but directly use the data in ticks to determine at what points on the axis a tick need to be generated.\n *   ticks: [1, 10, 20, 30]\n * };\n * ```\n *\n * @module Chartist.FixedScaleAxis\n */\n/* global Chartist */\n(function (window, document, Chartist) {\n  'use strict';\n\n  function FixedScaleAxis(axisUnit, data, chartRect, options) {\n    var highLow = options.highLow || Chartist.getHighLow(data.normalized, options, axisUnit.pos);\n    this.divisor = options.divisor || 1;\n    this.ticks = options.ticks || Chartist.times(this.divisor).map(function(value, index) {\n      return highLow.low + (highLow.high - highLow.low) / this.divisor * index;\n    }.bind(this));\n    this.ticks.sort(function(a, b) {\n      return a - b;\n    });\n    this.range = {\n      min: highLow.low,\n      max: highLow.high\n    };\n\n    Chartist.FixedScaleAxis.super.constructor.call(this,\n      axisUnit,\n      chartRect,\n      this.ticks,\n      options);\n\n    this.stepLength = this.axisLength / this.divisor;\n  }\n\n  function projectValue(value) {\n    return this.axisLength * (+Chartist.getMultiValue(value, this.units.pos) - this.range.min) / (this.range.max - this.range.min);\n  }\n\n  Chartist.FixedScaleAxis = Chartist.Axis.extend({\n    constructor: FixedScaleAxis,\n    projectValue: projectValue\n  });\n\n}(window, document, Chartist));\n;/**\n * The step axis for step based charts like bar chart or step based line charts. It uses a fixed amount of ticks that will be equally distributed across the whole axis length. The projection is done using the index of the data value rather than the value itself and therefore it's only useful for distribution purpose.\n * **Options**\n * The following options are used by this axis in addition to the default axis options outlined in the axis configuration of the chart default settings.\n * ```javascript\n * var options = {\n *   // Ticks to be used to distribute across the axis length. As this axis type relies on the index of the value rather than the value, arbitrary data that can be converted to a string can be used as ticks.\n *   ticks: ['One', 'Two', 'Three'],\n *   // If set to true the full width will be used to distribute the values where the last value will be at the maximum of the axis length. If false the spaces between the ticks will be evenly distributed instead.\n *   stretch: true\n * };\n * ```\n *\n * @module Chartist.StepAxis\n */\n/* global Chartist */\n(function (window, document, Chartist) {\n  'use strict';\n\n  function StepAxis(axisUnit, data, chartRect, options) {\n    Chartist.StepAxis.super.constructor.call(this,\n      axisUnit,\n      chartRect,\n      options.ticks,\n      options);\n\n    this.stepLength = this.axisLength / (options.ticks.length - (options.stretch ? 1 : 0));\n  }\n\n  function projectValue(value, index) {\n    return this.stepLength * index;\n  }\n\n  Chartist.StepAxis = Chartist.Axis.extend({\n    constructor: StepAxis,\n    projectValue: projectValue\n  });\n\n}(window, document, Chartist));\n;/**\n * The Chartist line chart can be used to draw Line or Scatter charts. If used in the browser you can access the global `Chartist` namespace where you find the `Line` function as a main entry point.\n *\n * For examples on how to use the line chart please check the examples of the `Chartist.Line` method.\n *\n * @module Chartist.Line\n */\n/* global Chartist */\n(function(window, document, Chartist){\n  'use strict';\n\n  /**\n   * Default options in line charts. Expand the code view to see a detailed list of options with comments.\n   *\n   * @memberof Chartist.Line\n   */\n  var defaultOptions = {\n    // Options for X-Axis\n    axisX: {\n      // The offset of the labels to the chart area\n      offset: 30,\n      // Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.\n      position: 'end',\n      // Allows you to correct label positioning on this axis by positive or negative x and y offset.\n      labelOffset: {\n        x: 0,\n        y: 0\n      },\n      // If labels should be shown or not\n      showLabel: true,\n      // If the axis grid should be drawn or not\n      showGrid: true,\n      // Interpolation function that allows you to intercept the value from the axis label\n      labelInterpolationFnc: Chartist.noop,\n      // Set the axis type to be used to project values on this axis. If not defined, Chartist.StepAxis will be used for the X-Axis, where the ticks option will be set to the labels in the data and the stretch option will be set to the global fullWidth option. This type can be changed to any axis constructor available (e.g. Chartist.FixedScaleAxis), where all axis options should be present here.\n      type: undefined\n    },\n    // Options for Y-Axis\n    axisY: {\n      // The offset of the labels to the chart area\n      offset: 40,\n      // Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.\n      position: 'start',\n      // Allows you to correct label positioning on this axis by positive or negative x and y offset.\n      labelOffset: {\n        x: 0,\n        y: 0\n      },\n      // If labels should be shown or not\n      showLabel: true,\n      // If the axis grid should be drawn or not\n      showGrid: true,\n      // Interpolation function that allows you to intercept the value from the axis label\n      labelInterpolationFnc: Chartist.noop,\n      // Set the axis type to be used to project values on this axis. If not defined, Chartist.AutoScaleAxis will be used for the Y-Axis, where the high and low options will be set to the global high and low options. This type can be changed to any axis constructor available (e.g. Chartist.FixedScaleAxis), where all axis options should be present here.\n      type: undefined,\n      // This value specifies the minimum height in pixel of the scale steps\n      scaleMinSpace: 20,\n      // Use only integer values (whole numbers) for the scale steps\n      onlyInteger: false\n    },\n    // Specify a fixed width for the chart as a string (i.e. '100px' or '50%')\n    width: undefined,\n    // Specify a fixed height for the chart as a string (i.e. '100px' or '50%')\n    height: undefined,\n    // If the line should be drawn or not\n    showLine: true,\n    // If dots should be drawn or not\n    showPoint: true,\n    // If the line chart should draw an area\n    showArea: false,\n    // The base for the area chart that will be used to close the area shape (is normally 0)\n    areaBase: 0,\n    // Specify if the lines should be smoothed. This value can be true or false where true will result in smoothing using the default smoothing interpolation function Chartist.Interpolation.cardinal and false results in Chartist.Interpolation.none. You can also choose other smoothing / interpolation functions available in the Chartist.Interpolation module, or write your own interpolation function. Check the examples for a brief description.\n    lineSmooth: true,\n    // Overriding the natural low of the chart allows you to zoom in or limit the charts lowest displayed value\n    low: undefined,\n    // Overriding the natural high of the chart allows you to zoom in or limit the charts highest displayed value\n    high: undefined,\n    // Padding of the chart drawing area to the container element and labels as a number or padding object {top: 5, right: 5, bottom: 5, left: 5}\n    chartPadding: {\n      top: 15,\n      right: 15,\n      bottom: 5,\n      left: 10\n    },\n    // When set to true, the last grid line on the x-axis is not drawn and the chart elements will expand to the full available width of the chart. For the last label to be drawn correctly you might need to add chart padding or offset the last label with a draw event handler.\n    fullWidth: false,\n    // If true the whole data is reversed including labels, the series order as well as the whole series data arrays.\n    reverseData: false,\n    // Override the class names that get used to generate the SVG structure of the chart\n    classNames: {\n      chart: 'ct-chart-line',\n      label: 'ct-label',\n      labelGroup: 'ct-labels',\n      series: 'ct-series',\n      line: 'ct-line',\n      point: 'ct-point',\n      area: 'ct-area',\n      grid: 'ct-grid',\n      gridGroup: 'ct-grids',\n      vertical: 'ct-vertical',\n      horizontal: 'ct-horizontal',\n      start: 'ct-start',\n      end: 'ct-end'\n    }\n  };\n\n  /**\n   * Creates a new chart\n   *\n   */\n  function createChart(options) {\n    this.data = Chartist.normalizeData(this.data);\n    var data = {\n      raw: this.data,\n      normalized: Chartist.getDataArray(this.data, options.reverseData, true)\n    };\n\n    // Create new svg object\n    this.svg = Chartist.createSvg(this.container, options.width, options.height, options.classNames.chart);\n    // Create groups for labels, grid and series\n    var gridGroup = this.svg.elem('g').addClass(options.classNames.gridGroup);\n    var seriesGroup = this.svg.elem('g');\n    var labelGroup = this.svg.elem('g').addClass(options.classNames.labelGroup);\n\n    var chartRect = Chartist.createChartRect(this.svg, options, defaultOptions.padding);\n    var axisX, axisY;\n\n    if(options.axisX.type === undefined) {\n      axisX = new Chartist.StepAxis(Chartist.Axis.units.x, data, chartRect, Chartist.extend({}, options.axisX, {\n        ticks: data.raw.labels,\n        stretch: options.fullWidth\n      }));\n    } else {\n      axisX = options.axisX.type.call(Chartist, Chartist.Axis.units.x, data, chartRect, options.axisX);\n    }\n\n    if(options.axisY.type === undefined) {\n      axisY = new Chartist.AutoScaleAxis(Chartist.Axis.units.y, data, chartRect, Chartist.extend({}, options.axisY, {\n        high: Chartist.isNum(options.high) ? options.high : options.axisY.high,\n        low: Chartist.isNum(options.low) ? options.low : options.axisY.low\n      }));\n    } else {\n      axisY = options.axisY.type.call(Chartist, Chartist.Axis.units.y, data, chartRect, options.axisY);\n    }\n\n    axisX.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);\n    axisY.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);\n\n    // Draw the series\n    data.raw.series.forEach(function(series, seriesIndex) {\n      var seriesElement = seriesGroup.elem('g');\n\n      // Write attributes to series group element. If series name or meta is undefined the attributes will not be written\n      seriesElement.attr({\n        'ct:series-name': series.name,\n        'ct:meta': Chartist.serialize(series.meta)\n      });\n\n      // Use series class from series data or if not set generate one\n      seriesElement.addClass([\n        options.classNames.series,\n        (series.className || options.classNames.series + '-' + Chartist.alphaNumerate(seriesIndex))\n      ].join(' '));\n\n      var pathCoordinates = [],\n        pathData = [];\n\n      data.normalized[seriesIndex].forEach(function(value, valueIndex) {\n        var p = {\n          x: chartRect.x1 + axisX.projectValue(value, valueIndex, data.normalized[seriesIndex]),\n          y: chartRect.y1 - axisY.projectValue(value, valueIndex, data.normalized[seriesIndex])\n        };\n        pathCoordinates.push(p.x, p.y);\n        pathData.push({\n          value: value,\n          valueIndex: valueIndex,\n          meta: Chartist.getMetaData(series, valueIndex)\n        });\n      }.bind(this));\n\n      var seriesOptions = {\n        lineSmooth: Chartist.getSeriesOption(series, options, 'lineSmooth'),\n        showPoint: Chartist.getSeriesOption(series, options, 'showPoint'),\n        showLine: Chartist.getSeriesOption(series, options, 'showLine'),\n        showArea: Chartist.getSeriesOption(series, options, 'showArea'),\n        areaBase: Chartist.getSeriesOption(series, options, 'areaBase')\n      };\n\n      var smoothing = typeof seriesOptions.lineSmooth === 'function' ?\n        seriesOptions.lineSmooth : (seriesOptions.lineSmooth ? Chartist.Interpolation.monotoneCubic() : Chartist.Interpolation.none());\n      // Interpolating path where pathData will be used to annotate each path element so we can trace back the original\n      // index, value and meta data\n      var path = smoothing(pathCoordinates, pathData);\n\n      // If we should show points we need to create them now to avoid secondary loop\n      // Points are drawn from the pathElements returned by the interpolation function\n      // Small offset for Firefox to render squares correctly\n      if (seriesOptions.showPoint) {\n\n        path.pathElements.forEach(function(pathElement) {\n          var point = seriesElement.elem('line', {\n            x1: pathElement.x,\n            y1: pathElement.y,\n            x2: pathElement.x + 0.01,\n            y2: pathElement.y\n          }, options.classNames.point).attr({\n            'ct:value': [pathElement.data.value.x, pathElement.data.value.y].filter(Chartist.isNum).join(','),\n            'ct:meta': pathElement.data.meta\n          });\n\n          this.eventEmitter.emit('draw', {\n            type: 'point',\n            value: pathElement.data.value,\n            index: pathElement.data.valueIndex,\n            meta: pathElement.data.meta,\n            series: series,\n            seriesIndex: seriesIndex,\n            axisX: axisX,\n            axisY: axisY,\n            group: seriesElement,\n            element: point,\n            x: pathElement.x,\n            y: pathElement.y\n          });\n        }.bind(this));\n      }\n\n      if(seriesOptions.showLine) {\n        var line = seriesElement.elem('path', {\n          d: path.stringify()\n        }, options.classNames.line, true);\n\n        this.eventEmitter.emit('draw', {\n          type: 'line',\n          values: data.normalized[seriesIndex],\n          path: path.clone(),\n          chartRect: chartRect,\n          index: seriesIndex,\n          series: series,\n          seriesIndex: seriesIndex,\n          axisX: axisX,\n          axisY: axisY,\n          group: seriesElement,\n          element: line\n        });\n      }\n\n      // Area currently only works with axes that support a range!\n      if(seriesOptions.showArea && axisY.range) {\n        // If areaBase is outside the chart area (< min or > max) we need to set it respectively so that\n        // the area is not drawn outside the chart area.\n        var areaBase = Math.max(Math.min(seriesOptions.areaBase, axisY.range.max), axisY.range.min);\n\n        // We project the areaBase value into screen coordinates\n        var areaBaseProjected = chartRect.y1 - axisY.projectValue(areaBase);\n\n        // In order to form the area we'll first split the path by move commands so we can chunk it up into segments\n        path.splitByCommand('M').filter(function onlySolidSegments(pathSegment) {\n          // We filter only \"solid\" segments that contain more than one point. Otherwise there's no need for an area\n          return pathSegment.pathElements.length > 1;\n        }).map(function convertToArea(solidPathSegments) {\n          // Receiving the filtered solid path segments we can now convert those segments into fill areas\n          var firstElement = solidPathSegments.pathElements[0];\n          var lastElement = solidPathSegments.pathElements[solidPathSegments.pathElements.length - 1];\n\n          // Cloning the solid path segment with closing option and removing the first move command from the clone\n          // We then insert a new move that should start at the area base and draw a straight line up or down\n          // at the end of the path we add an additional straight line to the projected area base value\n          // As the closing option is set our path will be automatically closed\n          return solidPathSegments.clone(true)\n            .position(0)\n            .remove(1)\n            .move(firstElement.x, areaBaseProjected)\n            .line(firstElement.x, firstElement.y)\n            .position(solidPathSegments.pathElements.length + 1)\n            .line(lastElement.x, areaBaseProjected);\n\n        }).forEach(function createArea(areaPath) {\n          // For each of our newly created area paths, we'll now create path elements by stringifying our path objects\n          // and adding the created DOM elements to the correct series group\n          var area = seriesElement.elem('path', {\n            d: areaPath.stringify()\n          }, options.classNames.area, true);\n\n          // Emit an event for each area that was drawn\n          this.eventEmitter.emit('draw', {\n            type: 'area',\n            values: data.normalized[seriesIndex],\n            path: areaPath.clone(),\n            series: series,\n            seriesIndex: seriesIndex,\n            axisX: axisX,\n            axisY: axisY,\n            chartRect: chartRect,\n            index: seriesIndex,\n            group: seriesElement,\n            element: area\n          });\n        }.bind(this));\n      }\n    }.bind(this));\n\n    this.eventEmitter.emit('created', {\n      bounds: axisY.bounds,\n      chartRect: chartRect,\n      axisX: axisX,\n      axisY: axisY,\n      svg: this.svg,\n      options: options\n    });\n  }\n\n  /**\n   * This method creates a new line chart.\n   *\n   * @memberof Chartist.Line\n   * @param {String|Node} query A selector query string or directly a DOM element\n   * @param {Object} data The data object that needs to consist of a labels and a series array\n   * @param {Object} [options] The options object with options that override the default options. Check the examples for a detailed list.\n   * @param {Array} [responsiveOptions] Specify an array of responsive option arrays which are a media query and options object pair => [[mediaQueryString, optionsObject],[more...]]\n   * @return {Object} An object which exposes the API for the created chart\n   *\n   * @example\n   * // Create a simple line chart\n   * var data = {\n   *   // A labels array that can contain any sort of values\n   *   labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],\n   *   // Our series array that contains series objects or in this case series data arrays\n   *   series: [\n   *     [5, 2, 4, 2, 0]\n   *   ]\n   * };\n   *\n   * // As options we currently only set a static size of 300x200 px\n   * var options = {\n   *   width: '300px',\n   *   height: '200px'\n   * };\n   *\n   * // In the global name space Chartist we call the Line function to initialize a line chart. As a first parameter we pass in a selector where we would like to get our chart created. Second parameter is the actual data object and as a third parameter we pass in our options\n   * new Chartist.Line('.ct-chart', data, options);\n   *\n   * @example\n   * // Use specific interpolation function with configuration from the Chartist.Interpolation module\n   *\n   * var chart = new Chartist.Line('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5],\n   *   series: [\n   *     [1, 1, 8, 1, 7]\n   *   ]\n   * }, {\n   *   lineSmooth: Chartist.Interpolation.cardinal({\n   *     tension: 0.2\n   *   })\n   * });\n   *\n   * @example\n   * // Create a line chart with responsive options\n   *\n   * var data = {\n   *   // A labels array that can contain any sort of values\n   *   labels: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],\n   *   // Our series array that contains series objects or in this case series data arrays\n   *   series: [\n   *     [5, 2, 4, 2, 0]\n   *   ]\n   * };\n   *\n   * // In addition to the regular options we specify responsive option overrides that will override the default configutation based on the matching media queries.\n   * var responsiveOptions = [\n   *   ['screen and (min-width: 641px) and (max-width: 1024px)', {\n   *     showPoint: false,\n   *     axisX: {\n   *       labelInterpolationFnc: function(value) {\n   *         // Will return Mon, Tue, Wed etc. on medium screens\n   *         return value.slice(0, 3);\n   *       }\n   *     }\n   *   }],\n   *   ['screen and (max-width: 640px)', {\n   *     showLine: false,\n   *     axisX: {\n   *       labelInterpolationFnc: function(value) {\n   *         // Will return M, T, W etc. on small screens\n   *         return value[0];\n   *       }\n   *     }\n   *   }]\n   * ];\n   *\n   * new Chartist.Line('.ct-chart', data, null, responsiveOptions);\n   *\n   */\n  function Line(query, data, options, responsiveOptions) {\n    Chartist.Line.super.constructor.call(this,\n      query,\n      data,\n      defaultOptions,\n      Chartist.extend({}, defaultOptions, options),\n      responsiveOptions);\n  }\n\n  // Creating line chart type in Chartist namespace\n  Chartist.Line = Chartist.Base.extend({\n    constructor: Line,\n    createChart: createChart\n  });\n\n}(window, document, Chartist));\n;/**\n * The bar chart module of Chartist that can be used to draw unipolar or bipolar bar and grouped bar charts.\n *\n * @module Chartist.Bar\n */\n/* global Chartist */\n(function(window, document, Chartist){\n  'use strict';\n\n  /**\n   * Default options in bar charts. Expand the code view to see a detailed list of options with comments.\n   *\n   * @memberof Chartist.Bar\n   */\n  var defaultOptions = {\n    // Options for X-Axis\n    axisX: {\n      // The offset of the chart drawing area to the border of the container\n      offset: 30,\n      // Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.\n      position: 'end',\n      // Allows you to correct label positioning on this axis by positive or negative x and y offset.\n      labelOffset: {\n        x: 0,\n        y: 0\n      },\n      // If labels should be shown or not\n      showLabel: true,\n      // If the axis grid should be drawn or not\n      showGrid: true,\n      // Interpolation function that allows you to intercept the value from the axis label\n      labelInterpolationFnc: Chartist.noop,\n      // This value specifies the minimum width in pixel of the scale steps\n      scaleMinSpace: 30,\n      // Use only integer values (whole numbers) for the scale steps\n      onlyInteger: false\n    },\n    // Options for Y-Axis\n    axisY: {\n      // The offset of the chart drawing area to the border of the container\n      offset: 40,\n      // Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.\n      position: 'start',\n      // Allows you to correct label positioning on this axis by positive or negative x and y offset.\n      labelOffset: {\n        x: 0,\n        y: 0\n      },\n      // If labels should be shown or not\n      showLabel: true,\n      // If the axis grid should be drawn or not\n      showGrid: true,\n      // Interpolation function that allows you to intercept the value from the axis label\n      labelInterpolationFnc: Chartist.noop,\n      // This value specifies the minimum height in pixel of the scale steps\n      scaleMinSpace: 20,\n      // Use only integer values (whole numbers) for the scale steps\n      onlyInteger: false\n    },\n    // Specify a fixed width for the chart as a string (i.e. '100px' or '50%')\n    width: undefined,\n    // Specify a fixed height for the chart as a string (i.e. '100px' or '50%')\n    height: undefined,\n    // Overriding the natural high of the chart allows you to zoom in or limit the charts highest displayed value\n    high: undefined,\n    // Overriding the natural low of the chart allows you to zoom in or limit the charts lowest displayed value\n    low: undefined,\n    // Padding of the chart drawing area to the container element and labels as a number or padding object {top: 5, right: 5, bottom: 5, left: 5}\n    chartPadding: {\n      top: 15,\n      right: 15,\n      bottom: 5,\n      left: 10\n    },\n    // Specify the distance in pixel of bars in a group\n    seriesBarDistance: 15,\n    // If set to true this property will cause the series bars to be stacked. Check the `stackMode` option for further stacking options.\n    stackBars: false,\n    // If set to 'overlap' this property will force the stacked bars to draw from the zero line.\n    // If set to 'accumulate' this property will form a total for each series point. This will also influence the y-axis and the overall bounds of the chart. In stacked mode the seriesBarDistance property will have no effect.\n    stackMode: 'accumulate',\n    // Inverts the axes of the bar chart in order to draw a horizontal bar chart. Be aware that you also need to invert your axis settings as the Y Axis will now display the labels and the X Axis the values.\n    horizontalBars: false,\n    // If set to true then each bar will represent a series and the data array is expected to be a one dimensional array of data values rather than a series array of series. This is useful if the bar chart should represent a profile rather than some data over time.\n    distributeSeries: false,\n    // If true the whole data is reversed including labels, the series order as well as the whole series data arrays.\n    reverseData: false,\n    // Override the class names that get used to generate the SVG structure of the chart\n    classNames: {\n      chart: 'ct-chart-bar',\n      horizontalBars: 'ct-horizontal-bars',\n      label: 'ct-label',\n      labelGroup: 'ct-labels',\n      series: 'ct-series',\n      bar: 'ct-bar',\n      grid: 'ct-grid',\n      gridGroup: 'ct-grids',\n      vertical: 'ct-vertical',\n      horizontal: 'ct-horizontal',\n      start: 'ct-start',\n      end: 'ct-end'\n    }\n  };\n\n  /**\n   * Creates a new chart\n   *\n   */\n  function createChart(options) {\n    this.data = Chartist.normalizeData(this.data);\n    var data = {\n      raw: this.data,\n      normalized: options.distributeSeries ? Chartist.getDataArray(this.data, options.reverseData, options.horizontalBars ? 'x' : 'y').map(function(value) {\n        return [value];\n      }) : Chartist.getDataArray(this.data, options.reverseData, options.horizontalBars ? 'x' : 'y')\n    };\n\n    var highLow;\n\n    // Create new svg element\n    this.svg = Chartist.createSvg(\n      this.container,\n      options.width,\n      options.height,\n      options.classNames.chart + (options.horizontalBars ? ' ' + options.classNames.horizontalBars : '')\n    );\n\n    // Drawing groups in correct order\n    var gridGroup = this.svg.elem('g').addClass(options.classNames.gridGroup);\n    var seriesGroup = this.svg.elem('g');\n    var labelGroup = this.svg.elem('g').addClass(options.classNames.labelGroup);\n\n    if(options.stackBars && data.normalized.length !== 0) {\n      // If stacked bars we need to calculate the high low from stacked values from each series\n      var serialSums = Chartist.serialMap(data.normalized, function serialSums() {\n        return Array.prototype.slice.call(arguments).map(function(value) {\n          return value;\n        }).reduce(function(prev, curr) {\n          return {\n            x: prev.x + (curr && curr.x) || 0,\n            y: prev.y + (curr && curr.y) || 0\n          };\n        }, {x: 0, y: 0});\n      });\n\n      highLow = Chartist.getHighLow([serialSums], Chartist.extend({}, options, {\n        referenceValue: 0\n      }), options.horizontalBars ? 'x' : 'y');\n    } else {\n      highLow = Chartist.getHighLow(data.normalized, Chartist.extend({}, options, {\n        referenceValue: 0\n      }), options.horizontalBars ? 'x' : 'y');\n    }\n    // Overrides of high / low from settings\n    highLow.high = +options.high || (options.high === 0 ? 0 : highLow.high);\n    highLow.low = +options.low || (options.low === 0 ? 0 : highLow.low);\n\n    var chartRect = Chartist.createChartRect(this.svg, options, defaultOptions.padding);\n\n    var valueAxis,\n      labelAxisTicks,\n      labelAxis,\n      axisX,\n      axisY;\n\n    // We need to set step count based on some options combinations\n    if(options.distributeSeries && options.stackBars) {\n      // If distributed series are enabled and bars need to be stacked, we'll only have one bar and therefore should\n      // use only the first label for the step axis\n      labelAxisTicks = data.raw.labels.slice(0, 1);\n    } else {\n      // If distributed series are enabled but stacked bars aren't, we should use the series labels\n      // If we are drawing a regular bar chart with two dimensional series data, we just use the labels array\n      // as the bars are normalized\n      labelAxisTicks = data.raw.labels;\n    }\n\n    // Set labelAxis and valueAxis based on the horizontalBars setting. This setting will flip the axes if necessary.\n    if(options.horizontalBars) {\n      if(options.axisX.type === undefined) {\n        valueAxis = axisX = new Chartist.AutoScaleAxis(Chartist.Axis.units.x, data, chartRect, Chartist.extend({}, options.axisX, {\n          highLow: highLow,\n          referenceValue: 0\n        }));\n      } else {\n        valueAxis = axisX = options.axisX.type.call(Chartist, Chartist.Axis.units.x, data, chartRect, Chartist.extend({}, options.axisX, {\n          highLow: highLow,\n          referenceValue: 0\n        }));\n      }\n\n      if(options.axisY.type === undefined) {\n        labelAxis = axisY = new Chartist.StepAxis(Chartist.Axis.units.y, data, chartRect, {\n          ticks: labelAxisTicks\n        });\n      } else {\n        labelAxis = axisY = options.axisY.type.call(Chartist, Chartist.Axis.units.y, data, chartRect, options.axisY);\n      }\n    } else {\n      if(options.axisX.type === undefined) {\n        labelAxis = axisX = new Chartist.StepAxis(Chartist.Axis.units.x, data, chartRect, {\n          ticks: labelAxisTicks\n        });\n      } else {\n        labelAxis = axisX = options.axisX.type.call(Chartist, Chartist.Axis.units.x, data, chartRect, options.axisX);\n      }\n\n      if(options.axisY.type === undefined) {\n        valueAxis = axisY = new Chartist.AutoScaleAxis(Chartist.Axis.units.y, data, chartRect, Chartist.extend({}, options.axisY, {\n          highLow: highLow,\n          referenceValue: 0\n        }));\n      } else {\n        valueAxis = axisY = options.axisY.type.call(Chartist, Chartist.Axis.units.y, data, chartRect, Chartist.extend({}, options.axisY, {\n          highLow: highLow,\n          referenceValue: 0\n        }));\n      }\n    }\n\n    // Projected 0 point\n    var zeroPoint = options.horizontalBars ? (chartRect.x1 + valueAxis.projectValue(0)) : (chartRect.y1 - valueAxis.projectValue(0));\n    // Used to track the screen coordinates of stacked bars\n    var stackedBarValues = [];\n\n    labelAxis.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);\n    valueAxis.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);\n\n    // Draw the series\n    data.raw.series.forEach(function(series, seriesIndex) {\n      // Calculating bi-polar value of index for seriesOffset. For i = 0..4 biPol will be -1.5, -0.5, 0.5, 1.5 etc.\n      var biPol = seriesIndex - (data.raw.series.length - 1) / 2;\n      // Half of the period width between vertical grid lines used to position bars\n      var periodHalfLength;\n      // Current series SVG element\n      var seriesElement;\n\n      // We need to set periodHalfLength based on some options combinations\n      if(options.distributeSeries && !options.stackBars) {\n        // If distributed series are enabled but stacked bars aren't, we need to use the length of the normaizedData array\n        // which is the series count and divide by 2\n        periodHalfLength = labelAxis.axisLength / data.normalized.length / 2;\n      } else if(options.distributeSeries && options.stackBars) {\n        // If distributed series and stacked bars are enabled we'll only get one bar so we should just divide the axis\n        // length by 2\n        periodHalfLength = labelAxis.axisLength / 2;\n      } else {\n        // On regular bar charts we should just use the series length\n        periodHalfLength = labelAxis.axisLength / data.normalized[seriesIndex].length / 2;\n      }\n\n      // Adding the series group to the series element\n      seriesElement = seriesGroup.elem('g');\n\n      // Write attributes to series group element. If series name or meta is undefined the attributes will not be written\n      seriesElement.attr({\n        'ct:series-name': series.name,\n        'ct:meta': Chartist.serialize(series.meta)\n      });\n\n      // Use series class from series data or if not set generate one\n      seriesElement.addClass([\n        options.classNames.series,\n        (series.className || options.classNames.series + '-' + Chartist.alphaNumerate(seriesIndex))\n      ].join(' '));\n\n      data.normalized[seriesIndex].forEach(function(value, valueIndex) {\n        var projected,\n          bar,\n          previousStack,\n          labelAxisValueIndex;\n\n        // We need to set labelAxisValueIndex based on some options combinations\n        if(options.distributeSeries && !options.stackBars) {\n          // If distributed series are enabled but stacked bars aren't, we can use the seriesIndex for later projection\n          // on the step axis for label positioning\n          labelAxisValueIndex = seriesIndex;\n        } else if(options.distributeSeries && options.stackBars) {\n          // If distributed series and stacked bars are enabled, we will only get one bar and therefore always use\n          // 0 for projection on the label step axis\n          labelAxisValueIndex = 0;\n        } else {\n          // On regular bar charts we just use the value index to project on the label step axis\n          labelAxisValueIndex = valueIndex;\n        }\n\n        // We need to transform coordinates differently based on the chart layout\n        if(options.horizontalBars) {\n          projected = {\n            x: chartRect.x1 + valueAxis.projectValue(value && value.x ? value.x : 0, valueIndex, data.normalized[seriesIndex]),\n            y: chartRect.y1 - labelAxis.projectValue(value && value.y ? value.y : 0, labelAxisValueIndex, data.normalized[seriesIndex])\n          };\n        } else {\n          projected = {\n            x: chartRect.x1 + labelAxis.projectValue(value && value.x ? value.x : 0, labelAxisValueIndex, data.normalized[seriesIndex]),\n            y: chartRect.y1 - valueAxis.projectValue(value && value.y ? value.y : 0, valueIndex, data.normalized[seriesIndex])\n          }\n        }\n\n        // If the label axis is a step based axis we will offset the bar into the middle of between two steps using\n        // the periodHalfLength value. Also we do arrange the different series so that they align up to each other using\n        // the seriesBarDistance. If we don't have a step axis, the bar positions can be chosen freely so we should not\n        // add any automated positioning.\n        if(labelAxis instanceof Chartist.StepAxis) {\n          // Offset to center bar between grid lines, but only if the step axis is not stretched\n          if(!labelAxis.options.stretch) {\n            projected[labelAxis.units.pos] += periodHalfLength * (options.horizontalBars ? -1 : 1);\n          }\n          // Using bi-polar offset for multiple series if no stacked bars or series distribution is used\n          projected[labelAxis.units.pos] += (options.stackBars || options.distributeSeries) ? 0 : biPol * options.seriesBarDistance * (options.horizontalBars ? -1 : 1);\n        }\n\n        // Enter value in stacked bar values used to remember previous screen value for stacking up bars\n        previousStack = stackedBarValues[valueIndex] || zeroPoint;\n        stackedBarValues[valueIndex] = previousStack - (zeroPoint - projected[labelAxis.counterUnits.pos]);\n\n        // Skip if value is undefined\n        if(value === undefined) {\n          return;\n        }\n\n        var positions = {};\n        positions[labelAxis.units.pos + '1'] = projected[labelAxis.units.pos];\n        positions[labelAxis.units.pos + '2'] = projected[labelAxis.units.pos];\n\n        if(options.stackBars && (options.stackMode === 'accumulate' || !options.stackMode)) {\n          // Stack mode: accumulate (default)\n          // If bars are stacked we use the stackedBarValues reference and otherwise base all bars off the zero line\n          // We want backwards compatibility, so the expected fallback without the 'stackMode' option\n          // to be the original behaviour (accumulate)\n          positions[labelAxis.counterUnits.pos + '1'] = previousStack;\n          positions[labelAxis.counterUnits.pos + '2'] = stackedBarValues[valueIndex];\n        } else {\n          // Draw from the zero line normally\n          // This is also the same code for Stack mode: overlap\n          positions[labelAxis.counterUnits.pos + '1'] = zeroPoint;\n          positions[labelAxis.counterUnits.pos + '2'] = projected[labelAxis.counterUnits.pos];\n        }\n\n        // Limit x and y so that they are within the chart rect\n        positions.x1 = Math.min(Math.max(positions.x1, chartRect.x1), chartRect.x2);\n        positions.x2 = Math.min(Math.max(positions.x2, chartRect.x1), chartRect.x2);\n        positions.y1 = Math.min(Math.max(positions.y1, chartRect.y2), chartRect.y1);\n        positions.y2 = Math.min(Math.max(positions.y2, chartRect.y2), chartRect.y1);\n\n        // Create bar element\n        bar = seriesElement.elem('line', positions, options.classNames.bar).attr({\n          'ct:value': [value.x, value.y].filter(Chartist.isNum).join(','),\n          'ct:meta': Chartist.getMetaData(series, valueIndex)\n        });\n\n        this.eventEmitter.emit('draw', Chartist.extend({\n          type: 'bar',\n          value: value,\n          index: valueIndex,\n          meta: Chartist.getMetaData(series, valueIndex),\n          series: series,\n          seriesIndex: seriesIndex,\n          axisX: axisX,\n          axisY: axisY,\n          chartRect: chartRect,\n          group: seriesElement,\n          element: bar\n        }, positions));\n      }.bind(this));\n    }.bind(this));\n\n    this.eventEmitter.emit('created', {\n      bounds: valueAxis.bounds,\n      chartRect: chartRect,\n      axisX: axisX,\n      axisY: axisY,\n      svg: this.svg,\n      options: options\n    });\n  }\n\n  /**\n   * This method creates a new bar chart and returns API object that you can use for later changes.\n   *\n   * @memberof Chartist.Bar\n   * @param {String|Node} query A selector query string or directly a DOM element\n   * @param {Object} data The data object that needs to consist of a labels and a series array\n   * @param {Object} [options] The options object with options that override the default options. Check the examples for a detailed list.\n   * @param {Array} [responsiveOptions] Specify an array of responsive option arrays which are a media query and options object pair => [[mediaQueryString, optionsObject],[more...]]\n   * @return {Object} An object which exposes the API for the created chart\n   *\n   * @example\n   * // Create a simple bar chart\n   * var data = {\n   *   labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],\n   *   series: [\n   *     [5, 2, 4, 2, 0]\n   *   ]\n   * };\n   *\n   * // In the global name space Chartist we call the Bar function to initialize a bar chart. As a first parameter we pass in a selector where we would like to get our chart created and as a second parameter we pass our data object.\n   * new Chartist.Bar('.ct-chart', data);\n   *\n   * @example\n   * // This example creates a bipolar grouped bar chart where the boundaries are limitted to -10 and 10\n   * new Chartist.Bar('.ct-chart', {\n   *   labels: [1, 2, 3, 4, 5, 6, 7],\n   *   series: [\n   *     [1, 3, 2, -5, -3, 1, -6],\n   *     [-5, -2, -4, -1, 2, -3, 1]\n   *   ]\n   * }, {\n   *   seriesBarDistance: 12,\n   *   low: -10,\n   *   high: 10\n   * });\n   *\n   */\n  function Bar(query, data, options, responsiveOptions) {\n    Chartist.Bar.super.constructor.call(this,\n      query,\n      data,\n      defaultOptions,\n      Chartist.extend({}, defaultOptions, options),\n      responsiveOptions);\n  }\n\n  // Creating bar chart type in Chartist namespace\n  Chartist.Bar = Chartist.Base.extend({\n    constructor: Bar,\n    createChart: createChart\n  });\n\n}(window, document, Chartist));\n;/**\n * The pie chart module of Chartist that can be used to draw pie, donut or gauge charts\n *\n * @module Chartist.Pie\n */\n/* global Chartist */\n(function(window, document, Chartist) {\n  'use strict';\n\n  /**\n   * Default options in line charts. Expand the code view to see a detailed list of options with comments.\n   *\n   * @memberof Chartist.Pie\n   */\n  var defaultOptions = {\n    // Specify a fixed width for the chart as a string (i.e. '100px' or '50%')\n    width: undefined,\n    // Specify a fixed height for the chart as a string (i.e. '100px' or '50%')\n    height: undefined,\n    // Padding of the chart drawing area to the container element and labels as a number or padding object {top: 5, right: 5, bottom: 5, left: 5}\n    chartPadding: 5,\n    // Override the class names that are used to generate the SVG structure of the chart\n    classNames: {\n      chartPie: 'ct-chart-pie',\n      chartDonut: 'ct-chart-donut',\n      series: 'ct-series',\n      slicePie: 'ct-slice-pie',\n      sliceDonut: 'ct-slice-donut',\n      label: 'ct-label'\n    },\n    // The start angle of the pie chart in degrees where 0 points north. A higher value offsets the start angle clockwise.\n    startAngle: 0,\n    // An optional total you can specify. By specifying a total value, the sum of the values in the series must be this total in order to draw a full pie. You can use this parameter to draw only parts of a pie or gauge charts.\n    total: undefined,\n    // If specified the donut CSS classes will be used and strokes will be drawn instead of pie slices.\n    donut: false,\n    // Specify the donut stroke width, currently done in javascript for convenience. May move to CSS styles in the future.\n    // This option can be set as number or string to specify a relative width (i.e. 100 or '30%').\n    donutWidth: 60,\n    // If a label should be shown or not\n    showLabel: true,\n    // Label position offset from the standard position which is half distance of the radius. This value can be either positive or negative. Positive values will position the label away from the center.\n    labelOffset: 0,\n    // This option can be set to 'inside', 'outside' or 'center'. Positioned with 'inside' the labels will be placed on half the distance of the radius to the border of the Pie by respecting the 'labelOffset'. The 'outside' option will place the labels at the border of the pie and 'center' will place the labels in the absolute center point of the chart. The 'center' option only makes sense in conjunction with the 'labelOffset' option.\n    labelPosition: 'inside',\n    // An interpolation function for the label value\n    labelInterpolationFnc: Chartist.noop,\n    // Label direction can be 'neutral', 'explode' or 'implode'. The labels anchor will be positioned based on those settings as well as the fact if the labels are on the right or left side of the center of the chart. Usually explode is useful when labels are positioned far away from the center.\n    labelDirection: 'neutral',\n    // If true the whole data is reversed including labels, the series order as well as the whole series data arrays.\n    reverseData: false,\n    // If true empty values will be ignored to avoid drawing unncessary slices and labels\n    ignoreEmptyValues: false\n  };\n\n  /**\n   * Determines SVG anchor position based on direction and center parameter\n   *\n   * @param center\n   * @param label\n   * @param direction\n   * @return {string}\n   */\n  function determineAnchorPosition(center, label, direction) {\n    var toTheRight = label.x > center.x;\n\n    if(toTheRight && direction === 'explode' ||\n      !toTheRight && direction === 'implode') {\n      return 'start';\n    } else if(toTheRight && direction === 'implode' ||\n      !toTheRight && direction === 'explode') {\n      return 'end';\n    } else {\n      return 'middle';\n    }\n  }\n\n  /**\n   * Creates the pie chart\n   *\n   * @param options\n   */\n  function createChart(options) {\n    this.data = Chartist.normalizeData(this.data);\n    var seriesGroups = [],\n      labelsGroup,\n      chartRect,\n      radius,\n      labelRadius,\n      totalDataSum,\n      startAngle = options.startAngle,\n      dataArray = Chartist.getDataArray(this.data, options.reverseData);\n\n    // Create SVG.js draw\n    this.svg = Chartist.createSvg(this.container, options.width, options.height,options.donut ? options.classNames.chartDonut : options.classNames.chartPie);\n    // Calculate charting rect\n    chartRect = Chartist.createChartRect(this.svg, options, defaultOptions.padding);\n    // Get biggest circle radius possible within chartRect\n    radius = Math.min(chartRect.width() / 2, chartRect.height() / 2);\n    // Calculate total of all series to get reference value or use total reference from optional options\n    totalDataSum = options.total || dataArray.reduce(function(previousValue, currentValue) {\n      return previousValue + currentValue;\n    }, 0);\n\n    var donutWidth = Chartist.quantity(options.donutWidth);\n    if (donutWidth.unit === '%') {\n      donutWidth.value *= radius / 100;\n    }\n\n    // If this is a donut chart we need to adjust our radius to enable strokes to be drawn inside\n    // Unfortunately this is not possible with the current SVG Spec\n    // See this proposal for more details: http://lists.w3.org/Archives/Public/www-svg/2003Oct/0000.html\n    radius -= options.donut ? donutWidth.value / 2  : 0;\n\n    // If labelPosition is set to `outside` or a donut chart is drawn then the label position is at the radius,\n    // if regular pie chart it's half of the radius\n    if(options.labelPosition === 'outside' || options.donut) {\n      labelRadius = radius;\n    } else if(options.labelPosition === 'center') {\n      // If labelPosition is center we start with 0 and will later wait for the labelOffset\n      labelRadius = 0;\n    } else {\n      // Default option is 'inside' where we use half the radius so the label will be placed in the center of the pie\n      // slice\n      labelRadius = radius / 2;\n    }\n    // Add the offset to the labelRadius where a negative offset means closed to the center of the chart\n    labelRadius += options.labelOffset;\n\n    // Calculate end angle based on total sum and current data value and offset with padding\n    var center = {\n      x: chartRect.x1 + chartRect.width() / 2,\n      y: chartRect.y2 + chartRect.height() / 2\n    };\n\n    // Check if there is only one non-zero value in the series array.\n    var hasSingleValInSeries = this.data.series.filter(function(val) {\n      return val.hasOwnProperty('value') ? val.value !== 0 : val !== 0;\n    }).length === 1;\n\n    //if we need to show labels we create the label group now\n    if(options.showLabel) {\n      labelsGroup = this.svg.elem('g', null, null, true);\n    }\n\n    // Draw the series\n    // initialize series groups\n    for (var i = 0; i < this.data.series.length; i++) {\n      // If current value is zero and we are ignoring empty values then skip to next value\n      if (dataArray[i] === 0 && options.ignoreEmptyValues) continue;\n\n      var series = this.data.series[i];\n      seriesGroups[i] = this.svg.elem('g', null, null, true);\n\n      // If the series is an object and contains a name or meta data we add a custom attribute\n      seriesGroups[i].attr({\n        'ct:series-name': series.name\n      });\n\n      // Use series class from series data or if not set generate one\n      seriesGroups[i].addClass([\n        options.classNames.series,\n        (series.className || options.classNames.series + '-' + Chartist.alphaNumerate(i))\n      ].join(' '));\n\n      var endAngle = startAngle + dataArray[i] / totalDataSum * 360;\n\n      // Use slight offset so there are no transparent hairline issues\n      var overlappigStartAngle = Math.max(0, startAngle - (i === 0 || hasSingleValInSeries ? 0 : 0.2));\n\n      // If we need to draw the arc for all 360 degrees we need to add a hack where we close the circle\n      // with Z and use 359.99 degrees\n      if(endAngle - overlappigStartAngle >= 359.99) {\n        endAngle = overlappigStartAngle + 359.99;\n      }\n\n      var start = Chartist.polarToCartesian(center.x, center.y, radius, overlappigStartAngle),\n        end = Chartist.polarToCartesian(center.x, center.y, radius, endAngle);\n\n      // Create a new path element for the pie chart. If this isn't a donut chart we should close the path for a correct stroke\n      var path = new Chartist.Svg.Path(!options.donut)\n        .move(end.x, end.y)\n        .arc(radius, radius, 0, endAngle - startAngle > 180, 0, start.x, start.y);\n\n      // If regular pie chart (no donut) we add a line to the center of the circle for completing the pie\n      if(!options.donut) {\n        path.line(center.x, center.y);\n      }\n\n      // Create the SVG path\n      // If this is a donut chart we add the donut class, otherwise just a regular slice\n      var pathElement = seriesGroups[i].elem('path', {\n        d: path.stringify()\n      }, options.donut ? options.classNames.sliceDonut : options.classNames.slicePie);\n\n      // Adding the pie series value to the path\n      pathElement.attr({\n        'ct:value': dataArray[i],\n        'ct:meta': Chartist.serialize(series.meta)\n      });\n\n      // If this is a donut, we add the stroke-width as style attribute\n      if(options.donut) {\n        pathElement.attr({\n          'style': 'stroke-width: ' + donutWidth.value + 'px'\n        });\n      }\n\n      // Fire off draw event\n      this.eventEmitter.emit('draw', {\n        type: 'slice',\n        value: dataArray[i],\n        totalDataSum: totalDataSum,\n        index: i,\n        meta: series.meta,\n        series: series,\n        group: seriesGroups[i],\n        element: pathElement,\n        path: path.clone(),\n        center: center,\n        radius: radius,\n        startAngle: startAngle,\n        endAngle: endAngle\n      });\n\n      // If we need to show labels we need to add the label for this slice now\n      if(options.showLabel) {\n        // Position at the labelRadius distance from center and between start and end angle\n        var labelPosition = Chartist.polarToCartesian(center.x, center.y, labelRadius, startAngle + (endAngle - startAngle) / 2),\n          interpolatedValue = options.labelInterpolationFnc(this.data.labels && !Chartist.isFalseyButZero(this.data.labels[i]) ? this.data.labels[i] : dataArray[i], i);\n\n        if(interpolatedValue || interpolatedValue === 0) {\n          var labelElement = labelsGroup.elem('text', {\n            dx: labelPosition.x,\n            dy: labelPosition.y,\n            'text-anchor': determineAnchorPosition(center, labelPosition, options.labelDirection)\n          }, options.classNames.label).text('' + interpolatedValue);\n\n          // Fire off draw event\n          this.eventEmitter.emit('draw', {\n            type: 'label',\n            index: i,\n            group: labelsGroup,\n            element: labelElement,\n            text: '' + interpolatedValue,\n            x: labelPosition.x,\n            y: labelPosition.y\n          });\n        }\n      }\n\n      // Set next startAngle to current endAngle.\n      // (except for last slice)\n      startAngle = endAngle;\n    }\n\n    this.eventEmitter.emit('created', {\n      chartRect: chartRect,\n      svg: this.svg,\n      options: options\n    });\n  }\n\n  /**\n   * This method creates a new pie chart and returns an object that can be used to redraw the chart.\n   *\n   * @memberof Chartist.Pie\n   * @param {String|Node} query A selector query string or directly a DOM element\n   * @param {Object} data The data object in the pie chart needs to have a series property with a one dimensional data array. The values will be normalized against each other and don't necessarily need to be in percentage. The series property can also be an array of value objects that contain a value property and a className property to override the CSS class name for the series group.\n   * @param {Object} [options] The options object with options that override the default options. Check the examples for a detailed list.\n   * @param {Array} [responsiveOptions] Specify an array of responsive option arrays which are a media query and options object pair => [[mediaQueryString, optionsObject],[more...]]\n   * @return {Object} An object with a version and an update method to manually redraw the chart\n   *\n   * @example\n   * // Simple pie chart example with four series\n   * new Chartist.Pie('.ct-chart', {\n   *   series: [10, 2, 4, 3]\n   * });\n   *\n   * @example\n   * // Drawing a donut chart\n   * new Chartist.Pie('.ct-chart', {\n   *   series: [10, 2, 4, 3]\n   * }, {\n   *   donut: true\n   * });\n   *\n   * @example\n   * // Using donut, startAngle and total to draw a gauge chart\n   * new Chartist.Pie('.ct-chart', {\n   *   series: [20, 10, 30, 40]\n   * }, {\n   *   donut: true,\n   *   donutWidth: 20,\n   *   startAngle: 270,\n   *   total: 200\n   * });\n   *\n   * @example\n   * // Drawing a pie chart with padding and labels that are outside the pie\n   * new Chartist.Pie('.ct-chart', {\n   *   series: [20, 10, 30, 40]\n   * }, {\n   *   chartPadding: 30,\n   *   labelOffset: 50,\n   *   labelDirection: 'explode'\n   * });\n   *\n   * @example\n   * // Overriding the class names for individual series as well as a name and meta data.\n   * // The name will be written as ct:series-name attribute and the meta data will be serialized and written\n   * // to a ct:meta attribute.\n   * new Chartist.Pie('.ct-chart', {\n   *   series: [{\n   *     value: 20,\n   *     name: 'Series 1',\n   *     className: 'my-custom-class-one',\n   *     meta: 'Meta One'\n   *   }, {\n   *     value: 10,\n   *     name: 'Series 2',\n   *     className: 'my-custom-class-two',\n   *     meta: 'Meta Two'\n   *   }, {\n   *     value: 70,\n   *     name: 'Series 3',\n   *     className: 'my-custom-class-three',\n   *     meta: 'Meta Three'\n   *   }]\n   * });\n   */\n  function Pie(query, data, options, responsiveOptions) {\n    Chartist.Pie.super.constructor.call(this,\n      query,\n      data,\n      defaultOptions,\n      Chartist.extend({}, defaultOptions, options),\n      responsiveOptions);\n  }\n\n  // Creating pie chart type in Chartist namespace\n  Chartist.Pie = Chartist.Base.extend({\n    constructor: Pie,\n    createChart: createChart,\n    determineAnchorPosition: determineAnchorPosition\n  });\n\n}(window, document, Chartist));\n\nreturn Chartist;\n\n}));\n"
  },
  {
    "path": "app/assets/javascripts/application/vendor/jquery.multibox.js",
    "content": "(function ($) {\n\n  'use strict';\n\n  function Multibox($el, options) {\n    this.$el = $el;\n    this.options = options;\n    this.draw();\n    this.listen();\n  }\n\n  Multibox.prototype.destroy = function destroy() {\n    this.$inputs.off();\n    this.$el.detach();\n    this.$container.replaceWith(this.$el);\n\n    if (this.previousType) {\n      this.$el.attr('type', this.previousType);\n    }\n  };\n\n  Multibox.prototype.draw = function draw() {\n    var classNames = this.options.classNames;\n    var inputAutofocus = this.$el.attr('autofocus');\n    var inputType = this.$el.attr('type');\n    var inputValue = this.$el.val();\n\n    var focusIndex;\n    var inputIndex;\n    var text;\n\n    if (inputType !== 'hidden') {\n      this.previousType = inputType;\n      this.$el.attr('type', 'hidden');\n    }\n\n    this.$container = $('<div />', {\n      'class': classNames.container\n    });\n\n    var size = Array.apply(null, Array(this.options.inputCount));\n\n    this.$inputs = $();\n\n    $.each(size, function () {\n      this.$inputs = this.$inputs.add($('<input />', {\n        'class': classNames.input,\n        maxlength: 1,\n        size: 1,\n        type: 'text'\n      }));\n    }.bind(this));\n\n    this.$container .append(this.$inputs);\n    this.$el.replaceWith(this.$container);\n    this.$container.append(this.$el);\n\n    text = this.filterString(inputValue);\n\n    if (text.length) {\n      inputIndex = this.setFromString(0, text);\n    }\n\n    if (inputAutofocus) {\n      if (inputIndex === undefined) {\n        focusIndex = 0;\n      } else {\n        focusIndex = (inputIndex == this.$inputs.length ? inputIndex - 1 : inputIndex);\n      }\n      this.$inputs.eq(focusIndex).focus();\n    }\n  };\n\n  Multibox.prototype.handleKeydown = function handleKeydown(event) {\n    var $input = $(event.target);\n    var $prev;\n\n    if (event.keyCode === 8) {\n      event.preventDefault();\n\n      $prev = $input.prev();\n\n      if ($prev.length) {\n        $prev.focus();\n      }\n\n      if (event.target.value) {\n        $input.val('');\n      } else {\n        $prev.val('');\n      }\n    }\n\n    this.update();\n  };\n\n  Multibox.prototype.handleInput = function handleInput(event) {\n    var $input = $(event.target);\n    var $next = $input.next();\n    var value = $input.val();\n    var filtered = this.filterString(value);\n\n    $input.val(filtered);\n\n    if (filtered && $next.length) {\n      $next.focus();\n    }\n\n    this.update();\n  };\n\n  Multibox.prototype.handlePaste = function handlePaste(event) {\n    event.preventDefault();\n\n    var $input = $(event.target);\n    var clipboardData = event.originalEvent.clipboardData;\n    var text = clipboardData.getData('text');\n\n    var filtered = this.filterString(text);\n\n    if (!filtered.length) return;\n\n    var inputIndex = this.setFromString(this.$inputs.index($input), filtered);\n    var focusIndex = (inputIndex == this.$inputs.length ? inputIndex - 1 : inputIndex);\n\n    this.$inputs.eq(focusIndex).focus();\n\n    this.update();\n  };\n\n  Multibox.prototype.listen = function listen() {\n    this.$inputs.on('input', this.handleInput.bind(this));\n    this.$inputs.on('keydown', this.handleKeydown.bind(this));\n    this.$inputs.on('paste', this.handlePaste.bind(this));\n  };\n\n  Multibox.prototype.filterString = function filterString(str) {\n    return str.replace(this.options.regex, '');\n  };\n\n  Multibox.prototype.setFromString = function setFromString(index, str) {\n    var inputIndex = index;\n    var strIndex = 0;\n\n    while (this.$inputs.eq(inputIndex).length && str[strIndex]) {\n      this.$inputs.eq(inputIndex).val(str[strIndex]);\n      inputIndex++;\n      strIndex++;\n    }\n\n    return inputIndex;\n  };\n\n  Multibox.prototype.update = function update() {\n    var values = [];\n    var value;\n\n    this.$inputs.each(function(i, input) {\n      values.push(input.value);\n    });\n\n    value = values.join('');\n\n    this.$el\n      .val(value)\n      .trigger('change');\n  };\n\n  $.fn.multibox = function multibox(options) {\n    var instance;\n\n    if (typeof options === 'object' || options == undefined) {\n      options = (options || {});\n\n      options = $.extend({}, {\n        classNames: {\n          container: 'multibox',\n          input: 'multibox-input'\n        },\n        inputCount: 4,\n        regex: /\\D/g\n      }, options);\n\n      if (this.length) {\n        instance = new Multibox(this, options);\n        this.data('multibox', instance);\n      }\n    } else if (options === 'destroy') {\n      if (this.data('multibox')) {\n        instance = this.data('multibox');\n        instance.destroy();\n        this.data('multibox', null);\n      }\n    }\n  };\n\n}(jQuery));\n"
  },
  {
    "path": "app/assets/stylesheets/application/application.scss",
    "content": "@import 'global/reset';\n@import 'global/variables';\n@import 'global/mixins';\n@import 'global/fonts';\n\n@import 'vendor/*';\n@import 'elements/*';\n@import 'components/*';\n@import 'global/utility';\n\nhtml.main {\n  font-family: 'Source Sans Pro', sans-serif;\n  font-size:14px;\n  height: 100%;\n  max-height: 100%;\n  background:$backgroundGrey;\n  body {\n    display:flex;\n    flex-direction: column;\n    height: 100%;\n    max-height: 100%;\n    overflow-x:hidden;\n  }\n}\n\nhtml.subPage {\n  font-family: 'Source Sans Pro', sans-serif;\n  font-size:14px;\n  background:$backgroundGrey;\n  body {\n    padding-top:100px;\n    padding-bottom:100px;\n  }\n  .subPage__logo {\n    margin-bottom:40px;\n    text-align:center;\n  }\n}\n\n.turbolinks-progress-bar {\n  background-color: $darkBlue;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_admin_stats.scss",
    "content": ".adminStats {\n  display:flex;\n}\n\n.adminStats__stat {\n  flex:1 1 auto;\n  text-align: center;\n  dt {\n    font-weight:300;\n    color:#999;\n  }\n  dd {\n    font-size:26px;\n    font-weight:bold;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_button_set.scss",
    "content": ".buttonSet {\n  .button {\n    margin-right:7px;\n  }\n}\n\n\n.buttonSet--center {\n  .button {\n    margin:0 5px;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_checkbox_list.scss",
    "content": ".checkboxList {\n  background:#fff;\n  border:1px solid #e4e8ef;\n  font:inherit;\n  width:100%;\n  color:$darkBlue;\n  font-weight:600;\n  appearance:none;\n  border-radius:4px;\n\n}\n\n.checkboxList__item {\n  padding:8px 10px;\n  display:flex;\n}\n\n.checkboxList__item + .checkboxList__item{\n  border-top:1px solid #e4e8ef;\n}\n\n.checkboxList__checkbox {\n  margin-right:15px;\n}\n\n.checkboxList__actualLabel {\n  color:$darkBlue;\n  font-weight:600;\n}\n\n\n.checkBoxList__text {\n  font-size:12px;\n  line-height:1.5;\n  color:$subBlue;\n  margin-top:3px;\n}\n\n.checkboxList__devEvent {\n  font-family:'Droid Sans Mono', fixed;\n  font-size:13px;\n  font-weight:bold;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_credential_list.scss",
    "content": ".credentialList {\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n}\n\n.credentialList__item {\n  background:#fff;\n}\n.credentialList__item:nth-child(even) {\n  background:none;\n}\n\n.credentialList__item + .credentialList__item {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n.credentialList__link {\n  padding:15px;\n  display:flex;\n  &:hover {\n    background:#f2f5f8 !important;\n  }\n}\n\n.credentialList__properties {\n  flex: 1 1 auto;\n  min-width:1px;\n}\n\n.credentialList__name {\n  font-size:16px;\n  font-weight:600;\n  margin-bottom:10px;\n  overflow:hidden;\n  text-overflow:ellipsis;\n  white-space:nowrap;\n  line-height:1.2;\n  .label {\n    vertical-align:2px;\n    margin-left:4px;\n  }\n}\n\n.credentialList__key {\n  font-size:12px;\n  font-family:'Droid Sans Mono', fixed;\n  color:#999;\n}\n\n.credentialList__type {\n  margin-right:10px;\n  width:40px;\n}\n\n.credentialList__usedAt {\n  flex: 0 1 auto;\n  max-width:150px;\n  text-align:right;\n  margin-left:25px;\n  font-size:12px;\n  line-height:1.4;\n  color:#999;\n}\n\n.credentialList__usedAt--active {\n  color:$green;\n  .credentialList__usedAtTitle {\n    background-color:$green;\n  }\n\n}\n\n.credentialList__usedAt--quiet {\n  color:#bac647;\n  .credentialList__usedAtTitle {\n    background-color:#bac647;\n  }\n\n}\n\n.credentialList__usedAt--dormant {\n  color:#c7ad46;\n  .credentialList__usedAtTitle {\n    background-color:#c7ad46;\n  }\n\n}\n\n.credentialList__usedAt--inactive {\n  color:#d05026;\n  .credentialList__usedAtTitle {\n    background-color:#d05026;\n  }\n}\n\n.credentialList__usedAtTitle {\n  margin-bottom:3px;\n  background-color:#999;\n  color:#fff;\n  display:inline-block;\n  padding:1px 4px;\n  font-size:10px;\n  border-radius:3px;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_danger_zone.scss",
    "content": ".dangerZone {\n  border:3px dashed $red;\n  border-radius:4px;\n  padding:25px;\n  color:$red;\n  background:lighten($red, 42%);\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_data_table.scss",
    "content": ".dataTable {\n  width:100%;\n  border:1px solid #ddd;\n  font-size:14px;\n  box-shadow:0 0 5px rgba(0,0,0,0.3);\n\n}\n\n.dataTable tr td {\n  border-left:1px solid #ddd;\n  padding:8px;\n  background:#fff;\n}\n\n.dataTable tr th {\n  text-align:left;\n  padding:8px;\n  background-color:#fffdf4;\n  font-weight:600;\n  vertical-align:top;\n}\n\n.dataTable thead td {\n  font-weight:600;\n  border-left:0 !important;\n  background:#fffdf4;\n  padding:8px 9px;\n  border-bottom:2px solid #222;\n}\n\n.dataTable tbody tr:nth-child(even) td {\n  background:#f5f5f5;\n}\n\n.dataTable tbody tr:hover td {\n  background:#ededed;\n}\n\n.dataTable__centerCell {\n  text-align:center;\n}\n\n.dataTable__rightCell {\n  text-align:right;\n}\n\n.dataTable__empty {\n  padding:40px 0 !important;\n  text-align:center;\n  color:#999;\n  font-style:italic;\n  font-size:12px;\n  &:hover {\n    background:#fff !important;\n  }\n}\n\n.dataTable__inputCell {\n  padding:0 !important;\n  input {\n    width:100%;\n    padding:8px;\n    border:0;\n    font:inherit;\n    background:transparent;\n    font-weight:bold;\n    color:#fb8424;\n  }\n}\n\n\n.dataTable__redRow {\n  td {\n    background-color:#fff0f1 !important;\n    color:#cd2f3b !important;\n    .u-link { color:#cd2f3b;}\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_delivery_list.scss",
    "content": ".deliveryList {\n  color:$darkBlue;\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n}\n\n.deliveryList__item {\n  background:#fff;\n  padding:15px;\n}\n.deliveryList__item:nth-child(even) {\n  background:none;\n}\n\n.deliveryList__item + .deliveryList__item {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n.deliveryList__top {\n  display:flex;\n  justify-content:space-between;\n  align-items:flex-start;\n}\n\n.deliveryList__time {\n}\n\n.deliveryList__status {\n  display:flex;\n}\n\n.deliveryList__secure {\n  height:12px;\n  margin-right:7px;\n  margin-top:2px;\n}\n\n.deliveryList__errorCode {\n  font-size:12px;\n  color:$subBlue;\n  margin-top:5px;\n}\n\n.deliveryList__error {\n  margin-top:5px;\n  font-size:12px;\n  color:$subBlue;\n}\n\n.deliveryList__error--output {\n  background:$subBlue;\n  color:#fff;\n  font-size:10px;\n  font-family:'Droid Sans Mono', fixed;\n  padding:10px;\n  border-radius:4px;\n  margin-top:8px;\n  word-wrap:break-word;\n}\n\n.deliveryList__error--output-ref {\n  opacity:0.5;\n}\n\n.deliveryList__item--header {\n  p + p {\n    margin-top:8px;\n  }\n}\n\n\n.deliveryList__techLink {\n  display:inline-block;\n  font-size:10px;\n  color:$subBlue;\n  margin-top:8px;\n  text-decoration: underline;\n}\n\n.deliveryList-removeLink {\n  text-align:right;\n  font-size:12px;\n  color:#999;\n  margin-top:15px;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_domain_list.scss",
    "content": ".domainList {\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n}\n\n.domainList__item {\n  display:block;\n  background:#fff;\n  padding:15px;\n  display:flex;\n  justify-content:space-between;\n}\n.domainList__item:nth-child(even) {\n  background:none;\n}\n\n.domainList__item + .domainList__item {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n.domainList__details {\n  flex: 1 1 auto;\n  min-width: 1px;\n}\n\n.domainList__properties {\n  text-align:right;\n  flex:0 0 auto;\n  margin-left:25px;\n}\n\n.domainList__name {\n  font-size:16px;\n  font-weight:600;\n  margin-bottom:6px;\n  overflow:hidden;\n  text-overflow:ellipsis;\n  span.label {\n    vertical-align:2px;\n  }\n}\n\n.domainList__verificationTime {\n  color:#999;\n}\n\n.domainList__links {\n  margin-top:12px;\n  display:flex;\n  justify-content:flex-end;\n  font-size:12px;\n  text-decoration: underline;\n  a {\n    margin-left:10px;\n  }\n}\n\n.domainList__delete {\n  color:$red;\n  margin-left:10px;\n}\n\n.domainList__verificationLink {\n  background:$blue;\n  color:#fff;\n  padding:1px 7px;\n  border-radius:4px;\n  font-size:12px;\n}\n\n.domainList__checks {\n  display:flex;\n}\n\n.domainList__check {\n  margin-right:15px;\n  font-size:12px;\n}\n\n.domainList__check--ok {\n  background:image-url('icons/tick-green.svg') no-repeat 0 3px / 12px;\n  padding-left:15px;\n  color:$green;\n}\n\n.domainList__check--neutral {\n  background:image-url('icons/tick-grey.svg') no-repeat 0 3px / 12px;\n  padding-left:15px;\n  color:#aaa;\n}\n\n.domainList__check--neutral-cross {\n  background:image-url('icons/cross-grey.svg') no-repeat 0 3px / 9px;\n  padding-left:12px;\n  color:#aaa;\n}\n\n\n.domainList__check--warning {\n  background:image-url('icons/cross-orange.svg') no-repeat 0 3px / 9px;\n  padding-left:12px;\n  color:$orange;\n}\n\n.domainList__check a:hover {\n  text-decoration:underline;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_endpoint_list.scss",
    "content": ".endpointList {\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n}\n\n.endpointList__item {\n  background:#fff;\n}\n.endpointList__item:nth-child(even) {\n  background:none;\n}\n\n.endpointList__item + .endpointList__item {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n.endpointList__link {\n  padding:15px;\n  display:block;\n  display:flex;\n  &:hover {\n    background:#f2f5f8 !important;\n  }\n}\n\n.endpointList__main {\n  width:60%;\n  flex: 1 1 auto;\n}\n\n\n.endpointList__details {\n  flex: 1 1 auto;\n  width:40%;\n}\n\n.endpointList__name {\n  font-size:16px;\n  font-weight:600;\n  margin-bottom:8px;\n}\n\n.endpointList__url {\n  font-size:12px;\n  color:#999;\n}\n\n.endpointList__details {\n  line-height:1.5;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_error_explanation.scss",
    "content": ".errorExplanation {\n  border:1px solid $orange;\n  margin-bottom:25px;\n  color:$orange;\n  padding:15px;\n  box-shadow:0 0 10px lighten($red, 30%);\n  background:#fff;\n  border-radius:4px;\n  line-height:1.5;\n}\n\n.errorExplanation h2 {\n  display:none;\n}\n\n.errorExplanation p {\n  display:none;\n}\n\n.errorExplanation ul li {\n  list-style:disc;\n  margin-left:20px;\n}\n\nhtml.subPage {\n  .errorExplanation {\n    background:none;\n    padding-left:15px;\n    line-height:1.3;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_field_set.scss",
    "content": ".fieldSet {\n\n}\n\n.fieldSet__field {\n  display:flex;\n}\n\n.fieldSet__field + .fieldSet__field {\n  margin-top:20px;\n}\n\n.fieldSet__label {\n  display:block;\n  font-weight:600;\n  text-transform: uppercase;\n  font-size:12px;\n  color:$darkBlue;\n  margin-top:11px;\n  width:20%;\n}\n\n.fieldSet--wide .fieldSet__label {\n  width:40%;\n}\n\n.fieldSet__input {\n  flex: 0 0 auto;\n  width:80%;\n}\n\n.fieldSet--wide .fieldSet__input {\n  width:60%;\n}\n\n.fieldSet__text {\n  font-size:12px;\n  line-height:1.5;\n  color:$subBlue;\n  margin-top:5px;\n}\n\n.fieldSetSubmit {\n  margin-left:20%;\n  margin-top:40px;\n  display:flex;\n}\n\n.fieldSetSubmit--wide {\n  margin-left:40%;\n}\n\n.fieldSetSubmit__delete {\n  flex: 1 0 auto;\n  text-align:right;\n  .button {\n    margin-right:0;\n  }\n}\n\n.fieldSet__title {\n  margin-top:40px;\n  font-weight:600;\n  font-size:16px;\n  margin-left:20%;\n  color:$blue;\n  border-bottom:2px solid #e4e8ef;\n  padding-bottom:5px;\n  margin-bottom:20px;\n}\n\n.fieldSet__title--noMargin {\n  margin-top:0;\n}\n\n.fieldSet__title--withSubText {\n  margin-bottom:5px;\n}\n\n.fieldSet__titleSubText {\n  margin-left:20%;\n  font-size:12px;\n  color:$subBlue;\n  line-height:1.5;\n  margin-bottom:20px;\n}\n\n.fieldSet--compact {\n  .fieldSet__field {\n    display:block;\n  }\n  .fieldSet__field + .fieldSet__field {\n    margin-top:0;\n  }\n\n  .fieldSet__label {\n    width:100%;\n    margin-bottom:5px;\n  }\n  .fieldSet__input {\n    width:100%;\n  }\n  .fieldSet__fieldPair {\n    display:flex;\n    justify-content: space-between;\n    .fieldSet__field {\n      width:48%;\n    }\n  }\n}\n\n.fieldSet__inputPair {\n  display:flex;\n  justify-content: space-between;\n  .input + .input {\n    margin-left:10px;\n  }\n}\n\n.fieldSet__checkboxListAfter {\n  margin-bottom:6px;\n}\n\n.fieldSet__selectList {\n  select + select {\n    margin-top:6px;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_flash_display.scss",
    "content": ""
  },
  {
    "path": "app/assets/stylesheets/application/components/_flash_message.scss",
    "content": "html.main .flashMessage {\n  position:fixed;\n  background:$red;\n  z-index:5000;\n  left:25px;\n  top:25px;\n  width:300px;\n  color:#fff;\n  padding:15px;\n  border-radius:4px;\n  font-size:16px;\n  box-shadow:0 0 15px rgba(0,0,0,0.8);\n  cursor:pointer;\n}\n\nhtml.main .flashMessage--notice {\n  background:$green;\n}\n\nhtml.subPage .flashMessage {\n  background:$red;\n  color:#fff;\n  font-size:14px;\n  padding:15px;\n  line-height:1.4;\n}\n\nhtml.subPage .flashMessage--notice {\n  background:$green;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_footer.scss",
    "content": ".footer__links {\n  display:flex;\n  margin-left:auto;\n  align-items: center;\n  font-size:13px;\n  color:#999;\n  li {\n    height:24px;\n\n  }\n  li + li {\n    margin-left:18px;\n  }\n  a {\n    text-decoration: underline;\n  }\n}\n\n.footer__name {\n  height:16px;\n  background:image-url('icon.svg') no-repeat 0 0;\n  background-size:16px;\n  padding-left:22px;\n  font-weight:bold;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_grid.scss",
    "content": ".row {\n  clear:both;\n  margin-left:-20px;\n  margin-right:-20px;\n  @include clearfix;\n}\n\n.row--noPadding {\n  margin-left:0;\n  margin-right:0;\n  .col {\n    padding-left:0;\n    padding-right:0;\n  }\n}\n\n.col {\n  float:left;\n  padding-left:20px;\n  padding-right:20px;\n}\n\n.col--1 { width:5%; }\n.col--2 { width:10%; }\n.col--3 { width:15%; }\n.col--4 { width:20%; }\n.col--5 { width:25%; }\n.col--6 { width:30%; }\n.col--7 { width:35%; }\n.col--8 { width:40%; }\n.col--9 { width:45%; }\n.col--10 { width:50%; }\n.col--11 { width:55%; }\n.col--12 { width:60%; }\n.col--13 { width:65%; }\n.col--14 { width:70%; }\n.col--15 { width:75%; }\n.col--16 { width:80%; }\n.col--17 { width:85%; }\n.col--18 { width:90%; }\n.col--19 { width:95%; }\n\n.row--2col {\n  margin-left:0;\n  margin-right:0;\n  .col:first-child {\n    padding-left:0;\n    padding-right:10px;\n  }\n  .col:last-child {\n    padding-left:10px;\n    padding-right:0;\n  }\n}\n\n@media(max-width: 1000px) {\n  .col--collapse {\n    width: 100%;\n    margin-bottom: 50px;\n\n    &:last-child {\n      margin-bottom: 0;\n    }\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_headers_list.scss",
    "content": ".headersList {\n\n}\n\n.headersList__item {\n  display:flex;\n  font-family:'Droid Sans Mono', fixed;\n  font-size:12px;\n  justify-content:space-between;\n  dt {\n    color:$blue;\n    width:30%;\n    text-align:right;\n    font-weight:bold;\n  }\n\n  dd {\n    width:68%;\n    word-wrap:break-word;\n\n  }\n}\n\n.headersList__item + .headersList__item {\n  margin-top:15px;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_inlineError.scss",
    "content": ".inlineError {\n  background:$red;\n  color:#fff;\n  padding:15px;\n  border-radius:4px;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_invoice_list.scss",
    "content": ".invoiceList {\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n}\n\n.invoiceList__item {\n  background:#fff;\n}\n.invoiceList__item:nth-child(even) {\n  background:none;\n}\n\n.invoiceList__item + .invoiceList__item {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n.invoiceList__link {\n  padding:15px;\n  display:flex;\n  &:hover {\n    background:#f2f5f8 !important;\n  }\n}\n\n.invoiceList__number {\n  width:70px;\n  flex: 0 0 auto;\n  font-weight:bold;\n}\n\n.invoiceList__date {\n  flex: 1 1 auto;\n}\n\n.invoiceList__total {\n  width:100px;\n  flex: 0 0 auto;\n}\n\n.invoiceList__status {\n  width:50px;\n  text-align: right;\n  flex:0 0 auto;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_ip_list.scss",
    "content": ".ipList {\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n}\n\n.ipList__item {\n  display:block;\n  background:#fff;\n  padding:15px;\n  justify-content:space-between;\n}\n.ipList__item:nth-child(even) {\n  background:none;\n}\n\n.ipList__item + .ipList__item {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n.ipList__name {\n  font-size:16px;\n  font-weight:600;\n  margin-bottom:10px;\n}\n\n.ipList__address {\n  display:flex;\n}\n\n.ipList__address + .ipList__address {\n  margin-top:5px;\n}\n\n.ipList__ipv4 {\n  width:120px;\n}\n\n.ipList__ipv6 {\n  width:200px;\n}\n\n.ipList__address--header {\n  font-size:12px;\n  color:#999;\n  border-bottom:1px solid #ccc;\n  padding-bottom:4px;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_ip_pool_rule_list.scss",
    "content": ".ipPoolRuleList {\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n}\n\n.ipPoolRuleList__item {\n  background:#fff;\n}\n.ipPoolRuleList__item:nth-child(even) {\n  background:none;\n}\n\n.ipPoolRuleList__item + .ipPoolRuleList__item {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n.ipPoolRuleList__link {\n  padding:15px;\n  display:block;\n  &:hover {\n    background:#f2f5f8 !important;\n  }\n}\n\n.ipPoolRuleList__condition {\n  display:flex;\n  dt {\n    width:180px;\n    color:#999;\n    padding-top:1px;\n  }\n  dd {\n    ul li {\n      line-height:1.4;\n    }\n  }\n}\n\n.ipPoolRuleList__condition + .ipPoolRuleList__condition {\n  margin-top:15px;\n}\n\n.ipPoolRuleListDefault {\n  text-align:center;\n  margin-top:25px;\n  color:#999;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_large_list.scss",
    "content": ".largeList {\n  font-size:16px;\n  color:$darkBlue;\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n}\n\n.largeList__item {\n  display:block;\n  background:#fff;\n}\n.largeList__item:nth-child(even) {\n  background:none;\n}\n\n.largeList__item + .largeList__item {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n\n.largeList__item.is-highlighted {\n  background:$blue;\n  color:#fff;\n}\n\n.largeList__link {\n  display:block;\n  padding:15px;\n}\n\n.largeList__link:hover {\n  background:$blue;\n  color:#fff;\n}\n\n.largeList__link:active {\n  background:darken($blue, 5%);\n}\n\n.largeList__subText {\n  color:$subBlue;\n  font-size:13px;\n  margin-top:5px;\n}\n\n.largeList__rightLabel {\n  float:right;\n  line-height:0.8;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_limit.scss",
    "content": ".limits {\n  font-size:16px;\n  color:$darkBlue;\n  border-radius:4px;\n  background:#fff;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n  display:flex;\n}\n\n.limits__limit {\n  flex: 1 1 auto;\n  width:50%;\n  padding:15px;\n  text-align:center;\n}\n\n.limits__limit + .limits__limit {\n  border-left:1px solid #efefef;\n}\n\n.limits__title {\n  font-size:14px;\n  margin-bottom:5px;\n  font-weight:600;\n}\n\n.limits__value {\n  font-size:32px;\n  font-weight:900;\n  color:$blue;\n}\n\n.limits__frequency {\n  font-size:14px;\n  color:#999\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_login_form.scss",
    "content": ".loginForm {}\n\n\n.loginForm__input {\n  margin-bottom: 15px;\n}\n\n.loginForm__submit {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n\n.loginForm__links {\n  font-size: 12px;\n  color: #999;\n  text-decoration: underline;\n  line-height: 1.7;\n}\n\n.loginForm__divider {\n  margin-top: 25px;\n  margin-bottom: 25px;\n  border-top: 1px solid #e4e8ef;\n}\n\n.loginForm__localTitle {\n  text-align: center;\n  margin-bottom: 15px;\n  color: #999;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_mail_graph.scss",
    "content": ".mailGraph {\n}\n\n.mailGraph__startTime {\n\n}\n\n.mailGraph__graph {\n  min-height:230px;\n  margin-bottom:4px;\n  .ct-series-a .ct-line { stroke:$blue;}\n  .ct-series-a .ct-area { fill:$blue;  fill-opacity:0.2;}\n  .ct-series-b .ct-line { stroke:$turquoise;}\n  .ct-series-b .ct-area { fill:$turquoise;fill-opacity:0.2;}\n\n  .ct-point { stroke-width: 0; }\n  .ct-line { stroke-width:1px; }\n  .ct-area { fill-opacity: 0.4; }\n}\n\n\n.mailGraph__empty {\n  margin:100px 0;\n  text-align:center;\n  color:#aaa;\n}\n\n.mailGraph__key {\n  font-size:12px;\n  margin-bottom:15px;\n  float:right;\n  li {\n    float:left;\n    margin-left:10px;\n    color:$turquoise;\n  }\n  li:before {\n    display:block;\n    float:left;\n    width:10px;\n    content: \" \";\n    margin-top:3px;\n    height:10px;\n    border:1px solid $turquoise;\n    background:lighten($turquoise, 20%);\n    margin-right:6px;\n  }\n\n  li.mailGraph__key--out {\n    color:$blue;\n    &:before {\n      border-color:$blue ;\n      background:lighten($blue, 30%);\n    }\n  }\n\n\n}\n\n.mailGraph__labels {\n  display:flex;\n  margin-left:40px;\n  justify-content:space-between;\n  font-size:12px;\n  color:#999;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_message_activity.scss",
    "content": ".messageActivity {\n\n}\n\n.messageActivity__event {\n  display:flex;\n}\n\n.messageActivity__event + .messageActivity__event {\n  border-top:1px solid #ddd;\n  padding-top:15px;\n  margin-top:15px;\n}\n\n.messageActivity__timestamp {\n  width:170px;\n  font-size:12px;\n  color:#999;\n  flex: 0 0 auto;\n}\n\n\n.messageActivity__details {\n  background:image-url('icons/conveyor.svg') no-repeat 0 2px / 24px;\n  padding-left:35px;\n}\n\n.messageActivity--detailsDelivery {\n  background-image:image-url('icons/truck.svg');\n}\n\n\n.messageActivity--detailsClick {\n  background:image-url('icons/mouse.svg') no-repeat 5px 2px / 12px;\n}\n\n.messageActivity--detailsLoad {\n  background-image:image-url('icons/eye.svg');\n}\n\n.messageActivity__subject {\n  font-weight:600;\n  font-size:14px;\n  word-break:break-all;\n}\n\n.messageActivity__extra {\n  margin-top:4px;\n  color:#999;\n  font-size:12px;\n  line-height:1.4;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_message_header.scss",
    "content": ".messageHeader {\n  margin:20px 35px;\n}\n\n.messageHeader__subject {\n  font-size:18px;\n  font-weight:700;\n  margin-bottom:6px;\n}\n\n.messageHeader__status {\n  margin-bottom:4px;\n}\n\n.messageHeader__timestamp {\n  color:$subBlue;\n}\n\n.messageHeader__basicProperties {\n  display:flex;\n  dl {\n    margin-right:25px;\n    display:flex;\n    dt {\n      color:$subBlue;\n      margin-bottom:5px;\n      margin-right:15px;\n    }\n    dd {\n      font-weight:600;\n    }\n  }\n}\n\n\n.messageHeader__header {\n  background-size:35px;\n  background-repeat:no-repeat;\n  background-position:right 0;\n}\n\n.messageHeader__header--incoming {\n  background-image:image-url('icons/incoming-mail.svg');\n}\n\n.messageHeader__header--outgoing {\n  background-image:image-url('icons/outgoing-mail.svg');\n}\n\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_message_list.scss",
    "content": ".messageList {\n  box-shadow:0 0 10px rgba(0,0,0,0.15);\n  border-radius:4px;\n  overflow:hidden;\n}\n\n.messageList__message + .messageList__message {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n\n.messageList__link {\n  display:flex;\n  padding:15px;\n  background:#fff;\n  &:hover {\n    background:#f2f5f8 !important;\n  }\n}\n\n.messageList__message:nth-child(even) {\n  .messageList__link {\n    background:transparent;\n  }\n}\n\n.messageList__details {\n  flex: 1 1 auto;\n  overflow: hidden;\n  min-width: 1px;\n  background-repeat:no-repeat;\n  background-size:16px;\n  background-position:0 2px;\n  padding-left:25px;\n}\n\n.messageList__details--incoming {\n  background-image:image-url('icons/incoming-mail.svg');\n}\n\n.messageList__details--outgoing {\n  background-image:image-url('icons/outgoing-mail.svg');\n}\n\n\n.messageList__subject {\n  font-weight:600;\n  margin-bottom:7px;\n  line-height:1.4;\n  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n\n.messageList__addresses {\n  display:flex;\n  line-height:1.4;\n  font-size:12px;\n  dt {\n    font-weight:600;\n  }\n  dd {\n    margin-left:15px;\n    margin-right:25px;\n  }\n}\n\n\n.messageList__meta {\n  flex: 0 0 auto;\n  margin-left:15px;\n  justify-self: flex-end;\n  text-align:right;\n}\n\n.messageList__timestamp {\n  color:#999;\n  font-size:12px;\n  margin-bottom:5px;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_message_properties_page.scss",
    "content": ".messagePropertiesPage {\n  display:flex;\n  justify-content:space-between;\n}\n\n.messagePropertiesPage__left {\n  width:45%;\n}\n\n.messagePropertiesPage__right {\n  border-left:3px solid #eee;\n  padding-left:35px;\n  width:52%;\n}\n\n.messagePropertiesPage__property {\n  margin-bottom:25px;\n  min-width:1px;\n  dt {\n    color:$subBlue;\n    margin-bottom:3px;\n  }\n  dd {\n    font-size:16px;\n    font-weight:600;\n    text-overflow:ellipsis;\n    overflow:hidden;\n    white-space:nowrap;\n  }\n}\n\n.messagePropertiesPage__property--locked {\n  background:image-url('icons/lock.svg') no-repeat 0 1px / 14px;\n  padding-left:20px;\n}\n\n.messagePropertiesPage__propertyPair {\n  display:flex;\n  justify-content:space-between;\n  dl {\n    width:47%;\n  }\n}\n\n.messagePropertiesPage__title {\n  font-size:20px;\n  font-weight:700;\n  margin-bottom:25px;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_message_search.scss",
    "content": ".messageSearch {\n  margin-bottom:25px;\n  position:relative;\n}\n\n.messageSearch__help {\n  position:absolute;\n  z-index:100;\n  right:20px;\n  top:11px;\n  font-size:12px;\n  color:$subBlue;\n  text-decoration:underline;\n}\n\n.messageSearch__input {\n  width:100%;\n  margin:0;\n\n  border:2px solid #e0e7f3;\n  border-radius:25px;\n  padding:6px 13px;\n  font:inherit;\n  font-size:14px;\n  font-weight:600;\n  position:relative;\n  color:$darkBlue;\n  background:image-url('icons/search.svg') #fff no-repeat 12px 7px / 19px;\n  padding-left:38px;\n  padding-right:150px;\n  &::placeholder {\n    color:#98a5c0;\n    font-weight:300;\n  }\n  &:focus {\n    border-color:$blue;\n  }\n  &.is-spinning {\n    background-image:image-url('spinner-sub.gif');\n    background-position: 12px 5px;\n  }\n}\n\n.messageSearch__helpBox {\n  color:$darkBlue;\n  margin-top:25px;\n  border-radius:4px;\n  background:#fffdf1;\n  padding:25px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n  display:flex;\n  justify-content:space-between;\n}\n\n.messageSearch__left {\n  width:40%;\n}\n\n.messageSearch__helpBoxTitle {\n  font-size:18px;\n  font-weight:600;\n  margin-bottom:10px;\n}\n\n.messageSearch__helpBoxText {\n  line-height:1.5;\n  font-size:14px;\n  color:$subBlue;\n}\n\n.messageSearch__right {\n  width:55%;\n}\n\n.messageSearch__definition {\n  dt {\n    font-family:'Droid Sans Mono', fixed;\n    font-weight:bold;\n    font-size:15px;\n    color:$blue;\n  }\n  dd {\n    font-size:13px;\n    margin-top:4px;\n    code {\n      font-family:'Droid Sans Mono', fixed;\n      font-size:12px;\n      color:darken($blue, 15%);\n    }\n  }\n}\n\n.messageSearch__definition + .messageSearch__definition {\n  margin-top:18px;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_multibox.scss",
    "content": ".multibox {\n  display: flex;\n  justify-content: space-between;\n  margin: 40px 0;\n}\n\n.multibox__input {\n  width:80px;\n  height:70px;\n  font: inherit;\n  font-weight: 700;\n  font-size:38px;\n  text-align:center;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_nav_bar.scss",
    "content": ".navBar {\n  background:$veryDarkBlue;\n  padding:10px 35px;\n  color:#fff;\n  ul {\n    display:flex;\n  }\n}\n\n.navBar--secondary {\n  background:lighten($veryDarkBlue, 44%);\n  .navBar__link.is-active {\n    color:$veryDarkBlue;\n  }\n}\n\n.navBar--tertiary {\n  background:#fff;\n  border:1px solid lighten($subBlue, 30%);\n  border-left:0;\n  border-right:0;\n  .navBar__link {\n    color:$subBlue;\n  }\n  .navBar__link.is-active {\n    color:$veryDarkBlue;\n  }\n\n}\n\n.navBar__item:not(:last-child) {\n  margin-right:35px;\n}\n\n.navBar__link.is-active {\n  color:#8abdff;\n  font-weight:600;\n}\n\n.navBar__item--end {\n  margin-left:auto;\n}\n\n.navBar__link:hover {\n  text-decoration:underline;\n}\n\n.navBar__itemCounter {\n  background:$red;\n  border-radius:4px;\n  padding:2px 2px 1px 2px;\n  line-height:1;\n  font-size:10px;\n  vertical-align:1px;\n  font-weight:300;\n  min-width:20px;\n  display:inline-block;\n  text-align:center;\n  margin-left:5px;\n  &.is-empty {\n    background-color:$darkBlue;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_new_message_type.scss",
    "content": ".newMessageType {\n  background-repeat:no-repeat;\n  background-size:20px;\n  background-position:15px 15px;\n  background-color:#4bc9c5;\n  color:#fff;\n  padding:15px;\n  padding-left:46px;\n  border-radius:4px;\n  border:1px solid darken(#4bc9c5, 10%);\n}\n\n.newMessageType--outgoing {\n  background-color:#0e69d5;\n  border-color:darken(#0e69d5, 10%);\n  background-image:image-url('icons/outgoing-mail-white.svg');\n}\n\n.newMessageType--incoming {\n  background-image:image-url('icons/incoming-mail-white.svg');\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_no_data.scss",
    "content": ".noData {\n  text-align:center;\n  border-radius:4px;\n  padding:30px;\n  padding-top:50px;\n  box-shadow:0 0 10px rgba(0,0,0,0.15);\n}\n\n.noData--clean {\n  box-shadow:none;\n  background-color:transparent;\n}\n\n.noData__title {\n  font-size:22px;\n  margin-bottom:10px;\n  font-weight:700;\n}\n\n.noData__text {\n  color:#888;\n  font-size:16px;\n  line-height:1.5;\n}\n\n.noData__button {\n  margin-top:20px;\n}\n\n.noData__postButtonText {\n  margin:auto;\n  margin-top:15px;\n  line-height:1.5;\n  width:70%;\n  color:$subBlue;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_page_content.scss",
    "content": ".pageContent {\n  margin:35px;\n}\n\n.pageContent__intro {\n  font-size:20px;\n  line-height:30px;\n  font-weight:300;\n  color:$subBlue;\n}\n\n.pageContent__title {\n  font-size:20px;\n  margin-bottom:10px;\n  font-weight:700;\n  color:$darkBlue;\n}\n\n.pageContent--compact {\n  max-width:600px;\n  margin:60px auto;\n}\n\n.pageContent__subTitle {\n  font-size:18px;\n  font-weight:600;\n  border-bottom:1px solid #ddd;\n  padding-bottom:3px;\n  margin-bottom:10px;\n}\n\n.pageContent__text {\n  line-height:1.5;\n  margin-bottom:15px;\n  .label {\n    vertical-align:1px;\n    margin-right:2px;\n  }\n}\n\n.pageContent__pageEntriesInfo {\n  font-size:12px;\n  color:$subBlue;\n  margin-bottom:10px;\n}\n\n.pageContent__definitions {\n  overflow:hidden;\n  dt {\n    width:30%;\n    float:left;\n    color:$subBlue;\n  }\n  dd {\n    margin-left:35%;\n    margin-bottom:15px;\n    word-wrap:break-word;\n  }\n}\n\n.pageContent__definitionCode {\n  font-size:16px;\n  font-weight:bold;\n  font-family:'Droid Sans Mono', fixed;\n}\n\n.pageContent__definitionCode + .pageContent__definitionText {\n  margin-top:6px;\n}\n\n.pageContent__definitionText {\n  color:$subBlue;\n}\n\n.pageContent__list {\n  line-height:1.5;\n  li {\n    list-style:square;\n    margin-left:25px;\n  }\n  li + li {\n    margin-top:15px;\n  }\n}\n\n.pageContent__helpLink {\n  a {\n    background:image-url('icons/help.svg') no-repeat 0 2px / 15px;\n    padding-left:20px;\n    text-decoration:underline;\n    color:$blue;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_page_header.scss",
    "content": ".pageHeader {\n  background:$darkBlue;\n  padding:22px 25px;\n}\n\n.pageHeader__title {\n  font-size:26px;\n  font-weight:300;\n  color:#fff;\n}\n\n.pageHeader__titlePrevious {\n  opacity:0.2;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_pagination.scss",
    "content": "nav.pagination {\n  font-size:12px;\n  text-align:center;\n  margin:25px 0;\n  span.page.current, a {\n    color:$blue;\n    display:inline-block;\n    line-height:1.3;\n    border:1px solid lighten($subBlue, 25%);\n    padding:3px 10px;\n    text-decoration:none;\n    border-radius:4px;\n    background:#fff;\n    margin:0 2px;\n  }\n\n  a:hover {\n    background-color:lighten(#ccc, 20%);\n  }\n\n  span.page.current {\n    background:$blue;\n    color:#fff;\n    border-color:darken($blue, 15%);\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_rentention_limits.scss",
    "content": ".retentionLimits {\n}\n\n.retentionLimits__limit {\n  display:flex;\n  align-items: center;\n}\n\n.retentionLimits__limit + .retentionLimits__limit {\n  margin-top:25px;\n}\n\n.retentionLimits__label {\n  width:200px;\n  flex: 0 0 auto;\n  font-weight:bold;\n  margin-right:25px;\n}\n\n.retentionLimits__info {\n  border-left:4px solid $blue;\n  padding-left:25px;\n}\n\n.retentionLimits__value {\n  color:$blue;\n  font-size:22px;\n  font-weight:700;\n  margin-bottom:6px;\n}\n\n.retentionLimits__text {\n  color:$subBlue;\n  font-size:12px;\n  line-height:1.5;\n}\n\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_route_list.scss",
    "content": ".routeList {\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n}\n\n.routeList__item {\n  background:#fff;\n}\n.routeList__item:nth-child(even) {\n  background:none;\n}\n\n.routeList__item + .routeList__item {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n.routeList__link {\n  padding:15px;\n  display:block;\n  &:hover {\n    background:#f2f5f8 !important;\n  }\n}\n\n.routeList__name {\n  font-size:16px;\n  font-weight:600;\n  margin-bottom:13px;\n}\n\n.routeList__details {\n  display:flex;\n  justify-content:space-between;\n  min-width:1px;\n}\n\n.routeList__endpoint {\n  flex: 1 1 auto;\n  overflow:hidden;\n  color:#999;\n  text-overflow:ellipsis;\n  white-space:nowrap;\n  line-height:1.1;\n  background:image-url('icons/web.svg') no-repeat 0 0 / 12px;\n  padding-left:18px;\n  font-size:13px;\n}\n\n.routeList__endpoint--smtp_endpoint {\n  background-image:image-url('icons/email.svg');\n  background-size:12px;\n  background-position:0 1.5px;\n}\n\n.routeList__endpoint--address_endpoint {\n  background-image:image-url('icons/email.svg');\n  background-size:12px;\n  background-position:0 1.5px;\n}\n\n.routeList__spamMode {\n  font-size:12px;\n  color:#999;\n  margin-left:15px;\n  flex: 0 0 auto;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_route_name_input.scss",
    "content": ".routeNameInput {\n  display:flex;\n  align-items:center;\n}\n\n.routeNameInput__at {\n  margin:0 7px;\n  font-size:18px;\n  color:$subBlue;\n}\n\n.routeNameInput__name {\n  width:40%;\n}\n\n.routeNameInput__domain {\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_server_header.scss",
    "content": ".serverHeader {\n  background:$darkBlue;\n  padding:25px;\n  display:flex;\n  color:#fff;\n  position:relative;\n}\n\n.serverHeader__stripe {\n  right:-35px;\n  margin-top:-5px;\n  background:#636363;\n  font-size:10px;\n  font-weight:600;\n  text-transform: uppercase;\n  text-align:center;\n  width:130px;\n  padding:4px 0;\n  transform:rotate(45deg);\n  position:absolute;\n}\n\n.serverHeader__stripe--live {\n  background-color:$green;\n}\n\n.serverHeader__stripe--suspended {\n  background-color:$red;\n}\n\n.serverHeader__info {\n  flex: 1 0 auto;\n  padding:8px;\n}\n\n.serverHeader__stats {\n  background-color:$veryDarkBlue;\n  width:180px;\n  flex: 0 0 auto;\n  padding:15px;\n  border-radius:4px;\n  a:hover {\n    text-decoration:underline;\n  }\n}\n\n.serverHeader__usage {\n  background:green;\n  width:320px;\n  padding:15px;\n  margin-left:10px;\n  background-color:$veryDarkBlue;\n  flex: 0 0 auto;\n  border-radius:4px;\n}\n\n.serverHeader__title {\n  font-size:18px;\n  font-weight:700;\n  margin-bottom:10px;\n}\n\n.serverHeader__list {\n  line-height:1.5;\n  font-size:12px;\n}\n\n.serverHeader__list--ok {\n  color:$green;\n}\n\n.serverHeader__list--warning {\n  color:$orange;\n}\n\n.serverHeader__statsList {\n  line-height:1.8;\n  font-size:12px;\n  li {\n    padding-left:22px;\n    font-weight:300;\n  }\n}\n\n.serverHeader__stat-held {\n  background:image-url('icons/pause-white.svg') no-repeat 0 4px / 13px;\n  padding-left:22px;\n}\n\n.serverHeader__stat-queue {\n  background:image-url('icons/box-white.svg') no-repeat 0 4px / 13px;\n}\n\n.serverHeader__stat-size {\n  background:image-url('icons/size-white.svg') no-repeat 0 4px / 13px;\n}\n\n.serverHeader__stat-bounces {\n  background:image-url('icons/bats-white.svg') no-repeat 0 4px / 13px;\n}\n\n.serverHeader__usageTitle {\n  color:#566576;\n  font-size:12px;\n  font-weight: 600;\n  margin-bottom:5px;\n}\n\n.serverHeader__usageLine {\n  display:flex;\n  font-size:12px;\n  align-items:center;\n}\n\n.serverHeader__usageLine + .serverHeader__usageLine {\n  margin-top:6px;\n}\n\n.serverHeader__usageLineLabel {\n  flex: 1 0 auto;\n}\n\n.serverHeader__usageLineBar {\n  width:100px;\n  line-height:0;\n}\n\n.serverHeader__usageLineValue {\n  width:60px;\n  text-align:right;\n  font-weight:600;\n}\n\n.serverHeader__usageLineValueLarge {\n  width:300px;\n  text-align:right;\n  color:$subBlue;\n  b {\n    color:#fff;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_sidebar.scss",
    "content": ".sidebar {\n  width:250px;\n  background:#fff;\n  flex: 0 0 auto;\n  z-index:200;\n  box-shadow:5px 0 8px -2px rgba(0,0,0,0.1);\n  overflow-y: auto;\n  @include scrollbars(6px);\n}\n\n.sidebar__search {\n  background:$lightBlue;\n  border-bottom:1px solid #d1dcea;\n  padding:15px;\n}\n\n.sidebar__searchInput {\n  width:100%;\n  margin:0;\n  border:1px solid #e0e7f3;\n  border-radius:25px;\n  padding:6px 13px;\n  font:inherit;\n  font-size:12px;\n  font-weight:600;\n  color:$darkBlue;\n  background:image-url('icons/search.svg') #fff no-repeat 10px 7px / 17px;\n  padding-left:33px;\n  &::placeholder {\n    color:#98a5c0;\n    font-weight:300;\n  }\n  &:focus {\n    border-color:$blue;\n  }\n}\n\n.sidebar__placeholder {\n  margin:60px 20px;\n  text-align:center;\n  color:$subBlue;\n  font-size:18px;\n  line-height:1.5;\n  font-weight:300;\n}\n\n.sidebar__new {\n  text-align:center;\n  margin-top:15px;\n  margin-bottom:15px;\n  color:#999;\n  font-size:12px;\n  a:hover {\n    color:$green;\n  }\n  text-decoration: underline;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_sidebar_server_list.scss",
    "content": ".sidebarServerList {\n  font-size:12px;\n  color:$darkBlue;\n}\n\n.sidebarServerList__item {\n  border-bottom:1px solid #e6ebf0;\n}\n\n.sidebarServerList__link {\n  display: block;\n  padding:15px 20px;\n  &:hover {\n    background-color:#f2f5f8;\n  }\n}\n\n.sidebarServerList__link.is-active {\n  background-color:#f2f5f8;\n}\n\n.sidebarServerList__item.is-highlighted .sidebarServerList__link {\n  background-color:#fffedd;\n}\n\n.sidebarServerList__mode {\n  float:right;\n}\n\n.sidebarServerList__title {\n  font-size:13px;\n  font-weight:600;\n  margin-bottom:5px;\n}\n\n.sidebarServerList__quantity {\n  color:$subBlue;\n  font-size:11px;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_simple_pagination.scss",
    "content": ".simplePagination {\n  display:flex;\n  margin:25px 0;\n  justify-content:space-between;\n  font-size:12px;\n}\n\n.simplePagination__link {\n  color:$blue;\n  display:inline-block;\n  line-height:1.3;\n  border:1px solid lighten($subBlue, 25%);\n  padding:3px 10px;\n  text-decoration:none;\n  border-radius:4px;\n  background:#fff;\n  margin:0 2px;\n  &:hover {\n    border-color:$blue;\n  }\n}\n\n.simplePagination__next,\n.simplePagination__previous,\n.simplePagination__current {\n  width:33%;\n}\n\n.simplePagination__next {\n  text-align:right;\n}\n\n.simplePagination__current {\n  text-align:center;\n  color:$subBlue;\n  line-height:1.5;\n}\n\n.simplePagination__info {\n  font-weight:600;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_site_content.scss",
    "content": ".siteContent {\n  align-items: stretch;\n  display: flex;\n  flex: 1 1 auto;\n  overflow: hidden;\n}\n\n.siteContent__main {\n  flex: 1 1 auto;\n  z-index:100;\n  overflow-y:scroll;\n  overflow-x:hidden;\n}\n\n.siteContent__footer {\n  border-top:1px solid #efefef;\n  margin-top:20px;\n  padding:25px;\n  display:flex;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_site_header.scss",
    "content": ".siteHeader {\n  width:100%;\n  background:$blue;\n  flex: 0 0 auto;\n  color:#fff;\n  z-index:1000;\n}\n\n.siteHeader__inside {\n  display:flex;\n  padding:12px 16px;\n  align-items:center;\n}\n\n.siteHeader__remember {\n  background:$veryDarkBlue;\n  position:fixed;\n  top:20px;\n  right:20px;\n  padding:20px;\n  border-radius:4px;\n  color:#fff;\n  z-index:2000;\n}\n\n.siteHeader__rememberButtons {\n  margin-top:15px;\n}\n\n.siteHeader__rememberText {\n  line-height:1.5;\n  font-size:12px;\n  color:#999;\n}\n\n.siteHeader__rememberTextTitle {\n  font-weight:600;\n  color:#fff;\n  font-size:16px;\n}\n\n.siteHeader__logo {\n  display:block;\n  a {\n    margin:0;\n    font-size:14px;\n    font-weight:600;\n    display:block;\n  }\n}\n\n\n.siteHeader__version {\n  margin-left:5px;\n  color:#fff;\n  opacity:0.3;\n  font-size:12px;\n}\n\n.siteHeader__nav {\n  flex: 1 0 auto;\n  text-align:right;\n  font-size:12px;\n  display:flex;\n  justify-content:flex-end;\n}\n\n.siteHeader__navItem {\n  margin-left:18px;\n}\n\n.sideHeader__navItemLink {\n  text-decoration: underline;\n  opacity: 0.5;\n}\n\n.siteHeader__navLinkWithMenu {\n  background:image-url('icons/drop-arrow-white.svg') no-repeat right 6px / 8px;\n  padding-right:12px;\n}\n\n.siteHeader__navItem--user {\n  background:image-url('icons/user-white.svg') no-repeat 0 3px / 8px;\n  padding-left:13px;\n}\n\n.siteHeader__navItem--organization {\n  background:image-url('icons/organization-white.svg') no-repeat 0 2px / 12px;\n  padding-left:18px;\n}\n\n.siteHeader__subMenu {\n  position:absolute;\n  background:#fff;\n  z-index:1000;\n  color:$darkBlue;\n  text-align:left;\n  box-shadow:0 0 15px rgba(0,0,0,0.2);\n  border-radius:4px;\n  margin-left:-15px;\n  margin-top:-5px;\n  overflow:hidden;\n  display:none;\n}\n\n.siteHeader__navItem:hover .siteHeader__subMenu {\n  display:block;\n}\n\n\n.siteHeader__subMenuItem + .siteHeader__subMenuItem {\n  border-top:1px solid #e6ebf0;\n}\n\n.siteHeader__subMenuItem--header {\n  font-weight:600;\n  padding:5px 15px;\n  background:$lightBlue;\n  color:$blue;\n}\n\n.siteHeader__subMenuLink {\n  padding:10px 15px;\n  display:block;\n  &:hover {\n    background-color:#f2f5f8;\n  }\n}\n\n.siteHeader__subMenuItem--div {\n  border-top-width:2px !important;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_spam_check_list.scss",
    "content": ".spamCheckList {\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n}\n\n.spamCheckList__item {\n  display:block;\n  background:#fff;\n  padding:15px;\n  align-items:center;\n  display:flex;\n}\n.spamCheckList__item:nth-child(even) {\n  background:none;\n}\n\n.spamCheckList__item + .spamCheckList__item {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n.spamCheckList__score {\n  width:130px;\n  font-size:20px;\n  font-weight:900;\n  text-align:center;\n  flex: 0 0 auto;\n}\n\n.spamCheckList__score--positive {\n  color:$green;\n}\n\n.spamCheckList__score--negative {\n  color:$red;\n}\n\n.spamCheckList__score--neutral {\n  color:$subBlue;\n}\n\n\n\n.spamCheckList__details {\n  flex: 1 1 auto;\n}\n\n.spamCheckList__code {\n  font-family:'Droid Sans Mono';\n  font-size:12px;\n  color:$subBlue;\n  margin-bottom:3px;\n}\n\n.spamCheckList__description {\n  line-height:1.5;\n}\n\n.spamCheckList__item--total + .spamCheckList__item{\n  border-top-width:2px;\n  border-top-color:$subBlue;\n}\n\n.spamCheckList__details--total {\n\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_starter_credit_pack.scss",
    "content": ".starterCreditPack {\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n  background:image-url('starter_pack.png') #fff no-repeat 25px 20px;\n  background-size:100px;\n  padding:25px 25px 20px 155px;\n  line-height:1.5;\n}\n\n.starterCreditPack__text {\n  margin-bottom:10px;\n}\n\n.starterCreditPack__nextRenew {\n  font-size:12px;\n  color:#999;\n  margin-left:5px;\n  vertical-align:-2px;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_sub_page_box.scss",
    "content": ".subPageBox {\n  background:#fff;\n  border-radius:4px;\n  box-shadow:0 0 30px rgba(0,0,0,0.15);\n  width:300px;\n  margin:auto;\n  overflow:hidden;\n  border-top:5px solid $blue;\n}\n\n.subPageBox--wide {\n  width:500px;\n}\n\n.subPageBox__title {\n  background:$lightBlue;\n  border-bottom:1px solid #d1dcea;\n  padding:20px 25px;\n  color:$blue;\n  font-size:16px;\n  font-weight:600;\n  text-align: center;\n}\n\n.subPageBox__content {\n  padding:20px 25px;\n}\n\n.subPageBox__text {\n  color:$subBlue;\n  font-size:13px;\n  line-height:1.4;\n  margin-bottom:20px;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_suppression_list.scss",
    "content": ".suppressionList {\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n}\n\n.suppressionList__item {\n  background:#fff;\n  padding:15px;\n  display:flex;\n}\n.suppressionList__item:nth-child(even) {\n  background:none;\n}\n\n.suppressionList__item + .suppressionList__item {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n.suppressionList__left {\n  flex: 1 1 auto;\n}\n\n.suppressionList__right {\n  flex: 0 0 auto;\n  text-align: right;\n}\n\n.suppressionList__timestamp {\n  color:#999;\n  font-size:12px;\n}\n\n.suppressionList__address {\n  font-weight:600;\n  margin-bottom:5px;\n}\n\n.suppressionList__reason {\n  color:#999;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_suspension_box.scss",
    "content": ".suspensionBox {\n  background:#e2383a;\n  border-radius:4px;\n  color:#fff;\n  line-height:1.5;\n  padding:25px;\n  font-size:16px;\n}\n\n.suspensionBox__reason {\n  margin-top:5px;\n  font-size:14px;\n  opacity:0.7;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_title_with_links.scss",
    "content": ".titleWithLinks {\n  display:flex;\n  color:$darkBlue;\n  align-items:center;\n}\n\n.titleWithLinks__title {\n  flex: 1 1 auto;\n  font-size:23px;\n\n}\n\n.titleWithLinks__links {\n  flex: 1 1 auto;\n  display:flex;\n  justify-content: flex-end;\n  li + li {\n    margin-left:25px;\n  }\n}\n.titleWithLinks__link {\n  text-decoration: underline;\n  &:hover {\n    color:$blue;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_user_list.scss",
    "content": ".userList {\n  border-radius: 4px;\n  color: $darkBlue;\n  overflow: hidden;\n  box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);\n}\n\n.userList__item {\n  display: block;\n  background: #fff;\n  padding: 15px;\n  display: flex;\n  align-items: center;\n}\n\n.userList__item:nth-child(even) {\n  background: none;\n}\n\n.userList__item+.userList__item {\n  border-top: 1px solid lighten(#ccd4e0, 10%);\n}\n\n.userList__details {\n  flex: 1 1 auto;\n  margin: 0 0;\n}\n\n\n.userList__actions {\n  flex: 0 0 auto;\n  width: 120px;\n  font-size: 12px;\n  line-height: 1.5;\n  color: #999;\n\n  a {\n    text-decoration: underline;\n  }\n}\n\n.userList__name {\n  font-weight: 600;\n  font-size: 16px;\n  margin-bottom: 3px;\n}\n\n.userList__owner {\n  vertical-align: 2px;\n  margin-left: 5px;\n  background-color: $orange;\n}\n\n.userList__pending {\n  vertical-align: 2px;\n  margin-left: 5px;\n  background-color: #ccc;\n}\n\n.userList__tag {\n  vertical-align: 2px;\n  margin-left: 3px;\n}\n\n.userList__revoke {\n  color: $red;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_webhook_list.scss",
    "content": ".webhookList {\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n}\n\n.webhookList__item {\n  background:#fff;\n  padding:15px;\n}\n.webhookList__item:nth-child(even) {\n  background:none;\n}\n\n.webhookList__item + .webhookList__item {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n.webhookList__top {\n  display:flex;\n  align-items: center;\n  min-width:1px;\n}\n\n.webhookList__labels {\n  flex: 0 0 auto;\n  line-height:0;\n  margin-left:10px;\n  .label + .label {\n    margin-left:2px;\n  }\n}\n\n.webhookList__name {\n  font-weight:600;\n  flex: 1 1 auto;\n  overflow:hidden;\n  text-overflow:ellipsis;\n  line-height:1.4;\n}\n\n.webhookList__bottom {\n  display:flex;\n  margin-top:3px;\n  font-size:12px;\n}\n\n.webhookList__usageTime {\n  color:#999;\n  line-height:1.4;\n  flex: 1 1 auto;\n}\n\n.webhookList__links {\n  flex: 0 0 auto;\n  display:flex;\n}\n\n.webhookList__link {\n  a {\n    color:#999;\n    text-decoration: underline;\n  }\n}\n\n.webhookList__link + .webhookList__link {\n  margin-left:12px;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/components/_webhook_request_list.scss",
    "content": ".webhookRequestList {\n  border-radius:4px;\n  overflow:hidden;\n  box-shadow:0 0 10px rgba(0,0,0,0.2);\n}\n\n.webhookRequestList__item {\n  background:#fff;\n}\n.webhookRequestList__item:nth-child(even) {\n  background:none;\n}\n\n.webhookRequestList__item + .webhookRequestList__item {\n  border-top:1px solid lighten(#ccd4e0, 10%);\n}\n\n.webhookRequestList__link {\n  display:block;\n  padding:15px;\n  &:hover {\n    background:#f2f5f8 !important;\n  }\n}\n\n.webhookRequestList__top {\n  display:flex;\n  margin-bottom:6px;\n  font-size:12px;\n}\n\n.webhookRequestList__status {\n  margin-top:-1px;\n  margin-right:10px;\n}\n\n.webhookRequestList__time {\n  flex: 1 1 auto;\n  color:#999;\n}\n\n.webhookRequestList__event {\n  flex: 0 0 auto;\n  font-size:11px;\n  border:1px solid $subBlue;\n  color:$subBlue;\n  border-radius:3px;\n  padding:3px 6px;\n  margin-top:-2px;\n}\n\n\n.webhookRequestList__url {\n  flex: 1 1 auto;\n  overflow:hidden;\n  font-family:'Droid Sans Mono', fixed;\n  line-height:1.4;\n  text-overflow:ellipsis;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/elements/_bar.scss",
    "content": ".bar {\n  background:$darkBlue;\n  border-radius:10px;\n  display:inline-block;\n  height:5px;\n  width:100%;\n  overflow:hidden;\n}\n\n.bar__inner {\n  background:$blue;\n  display:inline-block;\n  border-radius:10px;\n  height:5px;\n  width:50%;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/elements/_button.scss",
    "content": ".button {\n  display: inline-block;\n  font: inherit;\n  border-radius: 4px;\n  appearance: none;\n  background: $blue;\n  color: #fff;\n  font-size: 14px !important;\n  margin: 0;\n  vertical-align: top;\n  padding: 6px 15px;\n  border: 2px solid transparent;\n  border-bottom: 2px solid darken($blue, 20%);\n\n  &:active {\n    background-color: darken($blue, 15%);\n  }\n\n  &:focus {\n    border-color: darken($blue, 15%);\n    background-color: lighten($blue, 5%);\n  }\n\n  &.is-spinning {\n    color: transparent;\n    background-repeat: no-repeat;\n    background-position: center center;\n    background-size: 25px;\n    background-image: image-url('button-spinner.gif');\n  }\n}\n\n.button--small {\n  font-size: 12px !important;\n  padding: 3px 10px;\n  border-width: 1px;\n}\n\n.button--positive {\n  background-color: $green;\n  border-bottom-color: darken($green, 15%);\n\n  &:active {\n    background-color: darken($green, 15%);\n  }\n\n  &:focus {\n    border-color: darken($green, 15%);\n    background-color: lighten($green, 5%);\n  }\n\n  &.is-spinning {\n    background-image: image-url('button-spinner-positive.gif');\n  }\n\n}\n\n\n.button--neutral {\n  background-color: #ccc;\n  border-bottom-color: darken(#ccc, 15%);\n\n  &:active {\n    background-color: darken(#ccc, 15%);\n  }\n\n  &:focus {\n    border-color: darken(#ccc, 15%);\n    background-color: lighten(#ccc, 5%);\n  }\n\n  &.is-spinning {\n    background-image: image-url('button-spinner-neutral.gif');\n  }\n}\n\n.button--danger {\n  background-color: $red;\n  border-bottom-color: darken($red, 15%);\n\n  &:active {\n    background-color: darken($red, 15%);\n  }\n\n  &:focus {\n    border-color: darken($red, 15%);\n    background-color: lighten($red, 5%);\n  }\n\n  &.is-spinning {\n    background-image: image-url('button-spinner-danger.gif');\n  }\n}\n\n.button--dark {\n  background-color: $darkBlue;\n  border-bottom-color: darken($darkBlue, 15%);\n\n  &:active {\n    background-color: darken($darkBlue, 15%);\n  }\n\n  &:focus {\n    border-color: darken($darkBlue, 15%);\n    background-color: lighten($darkBlue, 5%);\n  }\n\n  &.is-spinning {\n    background-image: image-url('button-spinner-dark.gif');\n  }\n\n}\n\n.button--full {\n  width: 100%;\n  text-align: center;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/elements/_code_block.scss",
    "content": ".codeBlock {\n  background:#909db0;\n  color:#fff;\n  padding:25px;\n  border-radius:4px;\n}\n\n.codeBlock--whitespace {\n  white-space:pre;\n  overflow-x:auto;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/elements/_input.scss",
    "content": ".input {\n  border:0;\n  padding:0;\n  margin:0;\n  background:#fff;\n  border:1px solid #e4e8ef;\n  padding:8px 10px;\n  font:inherit;\n  width:100%;\n  color:$darkBlue;\n  font-weight:600;\n  appearance:none;\n  border-radius:4px;\n}\n\n.input--onWhite {\n  background-color:$backgroundGrey;\n}\n\n.input:disabled, .input.is-disabled {\n  opacity:0.5;\n}\n\n.input:focus {\n  border-color:$blue;\n  background-color:#fff;\n}\n\n.input--danger {\n  color:$red;\n  border-width:2px;\n  border-color:lighten($red, 37%);\n}\n\n.input--area {\n  height:300px;\n}\n\n.input--smallArea {\n  height:120px;\n}\n\n.input--danger:focus {\n  border-color:$red;\n  color:$red;\n  background:#fff;\n}\n\n.input::placeholder {\n  color:#b5c0d0;\n  font-weight:400;\n}\n\n.input--select {\n  background: #fff image-url('icons/select-arrow.svg') right 12px top 50% / 16px 16px no-repeat;\n  cursor: pointer;\n}\n\n.input--code {\n  font-family:'Droid Sans Mono', fixed;\n  font-size:13px;\n}\n\n.inputPair {\n  display:flex;\n  justify-content: space-between;\n  .input {\n    width:49%;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/elements/_label.scss",
    "content": ".label {\n  display: inline-block;\n  background: #000;\n  color: #fff;\n  font-size: 9px;\n  text-transform: uppercase;\n  border-radius: 40px;\n  padding: 2px 6px;\n  line-height: 0.9;\n}\n\n.label--green {\n  background-color: $green;\n}\n\n.label--red {\n  background-color: $red;\n}\n\n.label--orange {\n  background-color: $orange;\n}\n\n.label--blue {\n  background-color: $blue;\n}\n\n.label--grey {\n  background-color: #999;\n}\n\n.label--turquoise {\n  background-color: $blue;\n}\n\n.label--purple {\n  background-color: $purple;\n}\n\n.label--large {\n  font-size: 11px;\n  padding: 4px 10px;\n}\n\n.label--serverStatus-live {\n  background-color: $green;\n}\n\n.label--serverStatus-development {\n  background-color: #636363;\n}\n\n.label--serverStatus-suspended {\n  background-color: $red;\n}\n\n.label--messageStatus-pending {\n  background-color: $subBlue;\n}\n\n.label--messageStatus-held {\n  background-color: #aaa;\n}\n\n.label--messageStatus-processed {\n  background-color: $green;\n}\n\n.label--messageStatus-sent {\n  background-color: $green;\n}\n\n.label--messageStatus-hard_fail {\n  background-color: $red;\n}\n\n.label--messageStatus-soft_fail {\n  background-color: $orange;\n}\n\n.label--messageStatus-bounced {\n  background-color: $red;\n}\n\n.label--messageStatus-hold_cancelled {\n  background-color: #ccc;\n}\n\n\n.label--credentialType-api {\n  background-color: $blue;\n}\n\n.label--credentialType-smtp {\n  background-color: $turquoise;\n}\n\n.label--credentialType-smtp_ip {\n  background-color: $orange;\n}\n\n.label--spamStatus-not_checked {\n  background: #aaa;\n}\n\n.label--spamStatus-spam {\n  background: $orange;\n}\n\n.label--spamStatus-not_spam {\n  background: $turquoise;\n}\n\n.label--http-status-2 {\n  background-color: $green;\n}\n\n.label--http-status-3 {\n  background-color: $orange;\n}\n\n.label--http-status-4,\n.label--http-status-5 {\n  background-color: $red;\n}\n\n.domainList__ssl {\n  color: $green;\n\n  &:hover {\n    text-decoration: underline;\n  }\n}\n\n.domainList__ssl--disabled {\n  color: #999;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/elements/_misc.scss",
    "content": ".returnPathTag {\n  background:image-url('icons/return-path.svg') no-repeat 0 4px / 10px;\n  padding-left:14px;\n}\n\n.returnPathTag--inMessageHeader {\n  background-size:14px;\n  padding-left:18px;\n}\n\n.warningBox {\n  background-color:#fff8e4;\n  border:1px solid #c8bc9b;\n  padding:15px;\n  line-height:1.4;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/elements/_spam_range.scss",
    "content": ".spamRangeLabel {\n  font-size:12px;\n  text-align:right;\n  margin-top:7px;\n}\n\n.spamRange {\n  -webkit-appearance: none; /* Hides the slider so that custom slider can be made */\n  width: 100%; /* Specific width is required for Firefox. */\n  background: transparent; /* Otherwise white in Chrome */\n  &:disabled {\n    opacity:0.5;\n  }\n}\n\n.spamRange::-webkit-slider-thumb {\n  -webkit-appearance: none;\n}\n\n.spamRange:focus {\n  outline: none; /* Removes the blue border. You should probably do some kind of focus styling for accessibility reasons though. */\n}\n\n.spamRange::-ms-track {\n  width: 100%;\n  cursor: pointer;\n\n  /* Hides the slider so custom styles can be added */\n  background: transparent;\n  border-color: transparent;\n  color: transparent;\n}\n\n/* Special styling for WebKit/Blink */\n.spamRange::-webkit-slider-thumb {\n  -webkit-appearance: none;\n  border: 2px solid #2b2e32;\n  height:25px;\n  width:25px;\n  border-radius: 50%;\n  background: #ffffff;\n  cursor: pointer;\n  margin-top: -7px; /* You need to specify a margin in Chrome, but in Firefox and IE it is automatic */\n}\n\n/* All the same stuff for Firefox */\n.spamRange::-moz-range-thumb {\n  border: 2px solid #2b2e32;\n  height:25px;\n  width:25px;\n  border-radius: 50%;\n  background: #ffffff;\n  cursor: pointer;\n}\n\n/* All the same stuff for IE */\n.spamRange::-ms-thumb {\n  border: 2px solid #2b2e32;\n  height:25px;\n  width:25px;\n  border-radius: 50%;\n  background: #ffffff;\n  cursor: pointer;\n}\n\n//\n// Track\n//\n\n.spamRange::-webkit-slider-runnable-track {\n  width: 100%;\n  height: 12px;\n  cursor: pointer;\n  border-radius:30px;\n  background: #3ff990; /* Old browsers */\n  background: -moz-linear-gradient(left,  #3ff990 0%, #197ec9 47%, #6c5c8b 100%); /* FF3.6-15 */\n  background: -webkit-linear-gradient(left,  #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* Chrome10-25,Safari5.1-6 */\n  background: linear-gradient(to right,  #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */\n  filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3ff990', endColorstr='#6c5c8b',GradientType=1 ); /* IE6-9 */\n}\n\n.spamRange::-moz-range-track {\n  width: 100%;\n  height: 12px;\n  cursor: pointer;\n  border-radius:30px;\n  background: #3ff990; /* Old browsers */\n  background: -moz-linear-gradient(left,  #3ff990 0%, #197ec9 47%, #6c5c8b 100%); /* FF3.6-15 */\n  background: -webkit-linear-gradient(left,  #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* Chrome10-25,Safari5.1-6 */\n  background: linear-gradient(to right,  #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */\n  filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3ff990', endColorstr='#6c5c8b',GradientType=1 ); /* IE6-9 */\n}\n\n.spamRange::-ms-track {\n  width: 100%;\n  height: 12px;\n  cursor: pointer;\n  border-radius:30px;\n  background: transparent;\n  border-color: transparent;\n  background: #3ff990; /* Old browsers */\n  background: -moz-linear-gradient(left,  #3ff990 0%, #197ec9 47%, #6c5c8b 100%); /* FF3.6-15 */\n  background: -webkit-linear-gradient(left,  #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* Chrome10-25,Safari5.1-6 */\n  background: linear-gradient(to right,  #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */\n  filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3ff990', endColorstr='#6c5c8b',GradientType=1 ); /* IE6-9 */\n}\n\n.spamRange--hot::-webkit-slider-runnable-track {\n  background: #1688d0; /* Old browsers */\n  background: -moz-linear-gradient(left,  #1688d0 0%, #fa141b 100%); /* FF3.6-15 */\n  background: -webkit-linear-gradient(left,  #1688d0 0%,#fa141b 100%); /* Chrome10-25,Safari5.1-6 */\n  background: linear-gradient(to right,  #1688d0 0%,#fa141b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */\n  filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#1688d0', endColorstr='#fa141b',GradientType=1 ); /* IE6-9 */\n}\n\n.spamRange--hot::-moz-range-track {\n  background: #1688d0; /* Old browsers */\n  background: -moz-linear-gradient(left,  #1688d0 0%, #fa141b 100%); /* FF3.6-15 */\n  background: -webkit-linear-gradient(left,  #1688d0 0%,#fa141b 100%); /* Chrome10-25,Safari5.1-6 */\n  background: linear-gradient(to right,  #1688d0 0%,#fa141b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */\n  filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#1688d0', endColorstr='#fa141b',GradientType=1 ); /* IE6-9 */\n}\n\n.spamRange--hot::-ms-track {\n  background: #1688d0; /* Old browsers */\n  background: -moz-linear-gradient(left,  #1688d0 0%, #fa141b 100%); /* FF3.6-15 */\n  background: -webkit-linear-gradient(left,  #1688d0 0%,#fa141b 100%); /* Chrome10-25,Safari5.1-6 */\n  background: linear-gradient(to right,  #1688d0 0%,#fa141b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */\n  filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#1688d0', endColorstr='#fa141b',GradientType=1 ); /* IE6-9 */\n}\n\n\n.spamRange--blueGreen::-webkit-slider-runnable-track {\n  /* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#146dd2+0,7cc546+100 */\n  background: #146dd2; /* Old browsers */\n  background: -moz-linear-gradient(left,  #146dd2 0%, #7cc546 100%); /* FF3.6-15 */\n  background: -webkit-linear-gradient(left,  #146dd2 0%,#7cc546 100%); /* Chrome10-25,Safari5.1-6 */\n  background: linear-gradient(to right,  #146dd2 0%,#7cc546 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */\n  filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#146dd2', endColorstr='#7cc546',GradientType=1 ); /* IE6-9 */\n}\n\n.spamRange--blueGreen::-moz-range-track {\n  /* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#146dd2+0,7cc546+100 */\n  background: #146dd2; /* Old browsers */\n  background: -moz-linear-gradient(left,  #146dd2 0%, #7cc546 100%); /* FF3.6-15 */\n  background: -webkit-linear-gradient(left,  #146dd2 0%,#7cc546 100%); /* Chrome10-25,Safari5.1-6 */\n  background: linear-gradient(to right,  #146dd2 0%,#7cc546 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */\n  filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#146dd2', endColorstr='#7cc546',GradientType=1 ); /* IE6-9 */\n}\n\n.spamRange--blueGreen::-ms-track {\n  /* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#146dd2+0,7cc546+100 */\n  background: #146dd2; /* Old browsers */\n  background: -moz-linear-gradient(left,  #146dd2 0%, #7cc546 100%); /* FF3.6-15 */\n  background: -webkit-linear-gradient(left,  #146dd2 0%,#7cc546 100%); /* Chrome10-25,Safari5.1-6 */\n  background: linear-gradient(to right,  #146dd2 0%,#7cc546 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */\n  filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#146dd2', endColorstr='#7cc546',GradientType=1 ); /* IE6-9 */\n}\n\n"
  },
  {
    "path": "app/assets/stylesheets/application/global/_fonts.scss",
    "content": "@font-face {\n  font-family: \"Droid Sans Mono\";\n  src: font-url(\"DroidSansMono.eot\");\n  src: font-url(\"DroidSansMono.eot?#iefix\") format(\"embedded-opentype\"),\n    font-url(\"DroidSansMono.woff\") format(\"woff\"),\n    font-url(\"DroidSansMono.ttf\") format(\"truetype\");\n  font-weight: normal;\n  font-style: normal;\n}\n\n@font-face {\n  font-family: \"Source Sans Pro\";\n  src: font-url(\"SourceSansPro-Light.eot\");\n  src: font-url(\"SourceSansPro-Light.eot?#iefix\") format(\"embedded-opentype\"),\n    font-url(\"SourceSansPro-Light.woff\") format(\"woff\"),\n    font-url(\"SourceSansPro-Light.ttf\") format(\"truetype\");\n  font-weight: 300;\n  font-style: normal;\n}\n\n@font-face {\n  font-family: \"Source Sans Pro\";\n  src: font-url(\"SourceSansPro-Regular.eot\");\n  src: font-url(\"SourceSansPro-Regular.eot?#iefix\") format(\"embedded-opentype\"),\n    font-url(\"SourceSansPro-Regular.woff\") format(\"woff\"),\n    font-url(\"SourceSansPro-Regular.ttf\") format(\"truetype\");\n  font-weight: normal;\n  font-style: normal;\n}\n\n@font-face {\n  font-family: \"Source Sans Pro\";\n  src: font-url(\"SourceSansPro-Semibold.eot\");\n  src: font-url(\"SourceSansPro-Semibold.eot?#iefix\") format(\"embedded-opentype\"),\n    font-url(\"SourceSansPro-Semibold.woff\") format(\"woff\"),\n    font-url(\"SourceSansPro-Semibold.ttf\") format(\"truetype\");\n  font-weight: 600;\n  font-style: normal;\n}\n\n@font-face {\n  font-family: \"Source Sans Pro\";\n  src: font-url(\"SourceSansPro-Bold.eot\");\n  src: font-url(\"SourceSansPro-Bold.eot?#iefix\") format(\"embedded-opentype\"),\n    font-url(\"SourceSansPro-Bold.woff\") format(\"woff\"),\n    font-url(\"SourceSansPro-Bold.ttf\") format(\"truetype\");\n  font-weight: bold;\n  font-style: normal;\n}\n\n@font-face {\n  font-family: \"Source Sans Pro\";\n  src: font-url(\"SourceSansPro-Black.eot\");\n  src: font-url(\"SourceSansPro-Black.eot?#iefix\") format(\"embedded-opentype\"),\n    font-url(\"SourceSansPro-Black.woff\") format(\"woff\"),\n    font-url(\"SourceSansPro-Black.ttf\") format(\"truetype\");\n  font-weight: 900;\n  font-style: normal;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/global/_mixins.scss",
    "content": "@mixin scrollbars($size: 6px, $thumb: #979ea6, $track: #efefef) {\n  &::-webkit-scrollbar {\n    height: $size; // Horizontal Scrollbars\n    width: $size; // Vertical Scrollbars\n  }\n\n  &::-webkit-scrollbar-thumb {\n    background: $thumb;\n  }\n\n  &::-webkit-scrollbar-track {\n    background: $track;\n  }\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/global/_reset.scss",
    "content": "html, body, body div, span, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, abbr, address, cite, code, del, dfn, em, img, ins, kbd, q, samp, small, strong, sub, sup, var, b, i, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, figure, footer, header, hgroup, menu, nav, section, time, mark, audio, video {\n  margin: 0;\n  padding: 0;\n  border: 0;\n  outline: 0;\n  font-weight: normal;\n  font-size: 100%;\n  letter-spacing:0;\n  vertical-align: baseline;\n  background: transparent;\n}\n\nspan {\n  font-weight:inherit;\n}\n\narticle, aside, figure, footer, header, hgroup, nav, section {display: block;}\n\nimg,object,embed {max-width: 100%;}\nul {list-style: none;}\nblockquote, q {quotes: none;}\nb,strong { font-weight:bold;}\nstrong.semi { font-weight:600;}\nblockquote:before, blockquote:after, q:before, q:after {content: ''; content: none;}\n\na {margin: 0; padding: 0; font-size: 100%; vertical-align: baseline; background: transparent; color:inherit; text-decoration: none; line-height:1; margin:0 }\n\ndel {text-decoration: line-through;}\n\nabbr[title], dfn[title] {border-bottom: 1px dotted #000; cursor: help;}\n\n/* tables still need cellspacing=\"0\" in the markup */\ntable {border-collapse: collapse; border-spacing: 0;}\nth {font-weight: bold; vertical-align: bottom;}\ntd {font-weight: normal; vertical-align: top;}\n\nhr {display: block; height: 1px; border: 0; border-top: 3px solid #ddd; margin:0; padding: 0;}\n\ninput, select {vertical-align: middle;}\n\npre {\n  white-space: pre; /* CSS2 */\n  white-space: pre-wrap; /* CSS 2.1 */\n  white-space: pre-line; /* CSS 3 (and 2.1 as well, actually) */\n  word-wrap: break-word; /* IE */\n}\n\ninput[type=\"radio\"] {vertical-align: text-bottom;}\ninput[type=\"checkbox\"] {vertical-align: bottom; *vertical-align: baseline;}\n.ie6 input {vertical-align: text-bottom;}\n\nselect, input, textarea {font: 99% sans-serif;}\n\ntable {font: inherit;}\n\n/* Accessible focus treatment\n  people.opera.com/patrickl/experiments/keyboard/test */\na:hover, a:active {outline: none;}\n\nsmall {font-size: 85%;}\n\nstrong, th {font-weight: bold;}\n\ntd, td img {vertical-align: top;}\n\n/* Make sure sup and sub don't screw with your line-heights\n  gist.github.com/413930 */\nsub, sup {font-size: 75%; line-height: 0; position: relative;}\nsup {top: -0.5em;}\nsub {bottom: -0.25em;}\n\n/* standardize any monospaced elements */\npre, code, kbd, samp {font-family:  'Droid Sans Mono', fixed;}\n\n/* hand cursor on clickable elements */\nlabel,\ninput[type=button],\ninput[type=submit],\nbutton {\n  cursor: pointer;\n}\n\nbutton, input, select, textarea {\n  margin: 0;\n}\n\nbutton {\n  width: auto;\n  overflow: visible;\n  appearance: none;\n}\n\nselect, input, textarea, a, button {\n  outline: none;\n}\n\n*, *:before, *:after {\n  box-sizing: border-box;\n}\n\naddress {\n  font-style: normal;\n}\n\nth {\n  font-weight: initial;\n  text-align: left;\n}\n\nimg {\n  border: 0;\n}\n\n"
  },
  {
    "path": "app/assets/stylesheets/application/global/_utility.scss",
    "content": ".u-margin {\n  margin-bottom:25px;\n}\n\n.u-margin-half {\n  margin-bottom:10px;\n}\n\n.u-center {\n  text-align:center;\n}\n\n.u-green {\n  color:$green;\n}\n\n.u-orange {\n  color:$orange;\n}\n\n.u-grey {\n  color:#999;\n}\n.u-red {\n  color:$red;\n}\n\n.u-bold {\n  font-weight:600;\n}\n\n.u-link {\n  text-decoration: underline;\n}\n\n.is-hidden {\n  display:none;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/global/_variables.scss",
    "content": "$backgroundGrey: #fafafa;\n$blue: #0e69d5;\n$darkBlue: #3c4249;\n$veryDarkBlue: #2b2e32;\n$lightBlue: #eaf3fe;\n$subBlue: #909db0;\n$red: #e2383a;\n$green: #76c83b;\n$orange: #e8581f;\n$turquoise: #4ac7c5;\n$purple: #6145b2;\n\n@mixin clearfix {\n  &:after {\n    clear: both;\n    content: \" \";\n    display: table;\n  }\n}\n\n@mixin noselect {\n  -webkit-touch-callout: none;\n  -webkit-user-select: none;\n  -khtml-user-select: none;\n  -moz-user-select: none;\n  -ms-user-select: none;\n  user-select: none;\n}\n"
  },
  {
    "path": "app/assets/stylesheets/application/vendor/_chartist.scss",
    "content": ".ct-label {\n  fill: rgba(0, 0, 0, 0.4);\n  color: rgba(0, 0, 0, 0.4);\n  font-size: 0.75rem;\n  line-height: 1; }\n\n.ct-chart-line .ct-label,\n.ct-chart-bar .ct-label {\n  display: block;\n  display: -webkit-box;\n  display: -moz-box;\n  display: -ms-flexbox;\n  display: -webkit-flex;\n  display: flex; }\n\n.ct-label.ct-horizontal.ct-start {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-label.ct-horizontal.ct-end {\n  -webkit-box-align: flex-start;\n  -webkit-align-items: flex-start;\n  -ms-flex-align: flex-start;\n  align-items: flex-start;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-label.ct-vertical.ct-start {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: flex-end;\n  -webkit-justify-content: flex-end;\n  -ms-flex-pack: flex-end;\n  justify-content: flex-end;\n  text-align: right;\n  text-anchor: end; }\n\n.ct-label.ct-vertical.ct-end {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-chart-bar .ct-label.ct-horizontal.ct-start {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: center;\n  -webkit-justify-content: center;\n  -ms-flex-pack: center;\n  justify-content: center;\n  text-align: center;\n  text-anchor: start; }\n\n.ct-chart-bar .ct-label.ct-horizontal.ct-end {\n  -webkit-box-align: flex-start;\n  -webkit-align-items: flex-start;\n  -ms-flex-align: flex-start;\n  align-items: flex-start;\n  -webkit-box-pack: center;\n  -webkit-justify-content: center;\n  -ms-flex-pack: center;\n  justify-content: center;\n  text-align: center;\n  text-anchor: start; }\n\n.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-start {\n  -webkit-box-align: flex-end;\n  -webkit-align-items: flex-end;\n  -ms-flex-align: flex-end;\n  align-items: flex-end;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-end {\n  -webkit-box-align: flex-start;\n  -webkit-align-items: flex-start;\n  -ms-flex-align: flex-start;\n  align-items: flex-start;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: start; }\n\n.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-start {\n  -webkit-box-align: center;\n  -webkit-align-items: center;\n  -ms-flex-align: center;\n  align-items: center;\n  -webkit-box-pack: flex-end;\n  -webkit-justify-content: flex-end;\n  -ms-flex-pack: flex-end;\n  justify-content: flex-end;\n  text-align: right;\n  text-anchor: end; }\n\n.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-end {\n  -webkit-box-align: center;\n  -webkit-align-items: center;\n  -ms-flex-align: center;\n  align-items: center;\n  -webkit-box-pack: flex-start;\n  -webkit-justify-content: flex-start;\n  -ms-flex-pack: flex-start;\n  justify-content: flex-start;\n  text-align: left;\n  text-anchor: end; }\n\n.ct-grid {\n  stroke: rgba(0, 0, 0, 0.2);\n  stroke-width: 1px;\n  stroke-dasharray: 2px; }\n\n.ct-point {\n  stroke-width: 10px;\n  stroke-linecap: round; }\n\n.ct-line {\n  fill: none;\n  stroke-width: 4px; }\n\n.ct-area {\n  stroke: none;\n  fill-opacity: 0.1; }\n\n.ct-bar {\n  fill: none;\n  stroke-width: 10px; }\n\n.ct-slice-donut {\n  fill: none;\n  stroke-width: 60px; }\n\n.ct-series-a .ct-point, .ct-series-a .ct-line, .ct-series-a .ct-bar, .ct-series-a .ct-slice-donut {\n  stroke: #d70206; }\n\n.ct-series-a .ct-slice-pie, .ct-series-a .ct-area {\n  fill: #d70206; }\n\n.ct-series-b .ct-point, .ct-series-b .ct-line, .ct-series-b .ct-bar, .ct-series-b .ct-slice-donut {\n  stroke: #f05b4f; }\n\n.ct-series-b .ct-slice-pie, .ct-series-b .ct-area {\n  fill: #f05b4f; }\n\n.ct-series-c .ct-point, .ct-series-c .ct-line, .ct-series-c .ct-bar, .ct-series-c .ct-slice-donut {\n  stroke: #f4c63d; }\n\n.ct-series-c .ct-slice-pie, .ct-series-c .ct-area {\n  fill: #f4c63d; }\n\n.ct-series-d .ct-point, .ct-series-d .ct-line, .ct-series-d .ct-bar, .ct-series-d .ct-slice-donut {\n  stroke: #d17905; }\n\n.ct-series-d .ct-slice-pie, .ct-series-d .ct-area {\n  fill: #d17905; }\n\n.ct-series-e .ct-point, .ct-series-e .ct-line, .ct-series-e .ct-bar, .ct-series-e .ct-slice-donut {\n  stroke: #453d3f; }\n\n.ct-series-e .ct-slice-pie, .ct-series-e .ct-area {\n  fill: #453d3f; }\n\n.ct-series-f .ct-point, .ct-series-f .ct-line, .ct-series-f .ct-bar, .ct-series-f .ct-slice-donut {\n  stroke: #59922b; }\n\n.ct-series-f .ct-slice-pie, .ct-series-f .ct-area {\n  fill: #59922b; }\n\n.ct-series-g .ct-point, .ct-series-g .ct-line, .ct-series-g .ct-bar, .ct-series-g .ct-slice-donut {\n  stroke: #0544d3; }\n\n.ct-series-g .ct-slice-pie, .ct-series-g .ct-area {\n  fill: #0544d3; }\n\n.ct-series-h .ct-point, .ct-series-h .ct-line, .ct-series-h .ct-bar, .ct-series-h .ct-slice-donut {\n  stroke: #6b0392; }\n\n.ct-series-h .ct-slice-pie, .ct-series-h .ct-area {\n  fill: #6b0392; }\n\n.ct-series-i .ct-point, .ct-series-i .ct-line, .ct-series-i .ct-bar, .ct-series-i .ct-slice-donut {\n  stroke: #f05b4f; }\n\n.ct-series-i .ct-slice-pie, .ct-series-i .ct-area {\n  fill: #f05b4f; }\n\n.ct-series-j .ct-point, .ct-series-j .ct-line, .ct-series-j .ct-bar, .ct-series-j .ct-slice-donut {\n  stroke: #dda458; }\n\n.ct-series-j .ct-slice-pie, .ct-series-j .ct-area {\n  fill: #dda458; }\n\n.ct-series-k .ct-point, .ct-series-k .ct-line, .ct-series-k .ct-bar, .ct-series-k .ct-slice-donut {\n  stroke: #eacf7d; }\n\n.ct-series-k .ct-slice-pie, .ct-series-k .ct-area {\n  fill: #eacf7d; }\n\n.ct-series-l .ct-point, .ct-series-l .ct-line, .ct-series-l .ct-bar, .ct-series-l .ct-slice-donut {\n  stroke: #86797d; }\n\n.ct-series-l .ct-slice-pie, .ct-series-l .ct-area {\n  fill: #86797d; }\n\n.ct-series-m .ct-point, .ct-series-m .ct-line, .ct-series-m .ct-bar, .ct-series-m .ct-slice-donut {\n  stroke: #b2c326; }\n\n.ct-series-m .ct-slice-pie, .ct-series-m .ct-area {\n  fill: #b2c326; }\n\n.ct-series-n .ct-point, .ct-series-n .ct-line, .ct-series-n .ct-bar, .ct-series-n .ct-slice-donut {\n  stroke: #6188e2; }\n\n.ct-series-n .ct-slice-pie, .ct-series-n .ct-area {\n  fill: #6188e2; }\n\n.ct-series-o .ct-point, .ct-series-o .ct-line, .ct-series-o .ct-bar, .ct-series-o .ct-slice-donut {\n  stroke: #a748ca; }\n\n.ct-series-o .ct-slice-pie, .ct-series-o .ct-area {\n  fill: #a748ca; }\n\n.ct-square {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-square:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 100%; }\n  .ct-square:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-square > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-minor-second {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-minor-second:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 93.75%; }\n  .ct-minor-second:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-minor-second > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-second {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-second:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 88.8888888889%; }\n  .ct-major-second:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-second > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-minor-third {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-minor-third:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 83.3333333333%; }\n  .ct-minor-third:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-minor-third > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-third {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-third:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 80%; }\n  .ct-major-third:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-third > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-perfect-fourth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-perfect-fourth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 75%; }\n  .ct-perfect-fourth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-perfect-fourth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-perfect-fifth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-perfect-fifth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 66.6666666667%; }\n  .ct-perfect-fifth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-perfect-fifth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-minor-sixth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-minor-sixth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 62.5%; }\n  .ct-minor-sixth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-minor-sixth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-golden-section {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-golden-section:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 61.804697157%; }\n  .ct-golden-section:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-golden-section > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-sixth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-sixth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 60%; }\n  .ct-major-sixth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-sixth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-minor-seventh {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-minor-seventh:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 56.25%; }\n  .ct-minor-seventh:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-minor-seventh > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-seventh {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-seventh:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 53.3333333333%; }\n  .ct-major-seventh:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-seventh > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-octave {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-octave:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 50%; }\n  .ct-octave:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-octave > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-tenth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-tenth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 40%; }\n  .ct-major-tenth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-tenth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-eleventh {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-eleventh:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 37.5%; }\n  .ct-major-eleventh:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-eleventh > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-major-twelfth {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-major-twelfth:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 33.3333333333%; }\n  .ct-major-twelfth:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-major-twelfth > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n.ct-double-octave {\n  display: block;\n  position: relative;\n  width: 100%; }\n  .ct-double-octave:before {\n    display: block;\n    float: left;\n    content: \"\";\n    width: 0;\n    height: 0;\n    padding-bottom: 25%; }\n  .ct-double-octave:after {\n    content: \"\";\n    display: table;\n    clear: both; }\n  .ct-double-octave > svg {\n    display: block;\n    position: absolute;\n    top: 0;\n    left: 0; }\n\n/*# sourceMappingURL=chartist.css.map */\n"
  },
  {
    "path": "app/controllers/address_endpoints_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass AddressEndpointsController < ApplicationController\n\n  include WithinOrganization\n\n  before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }\n  before_action { params[:id] && @address_endpoint = @server.address_endpoints.find_by_uuid!(params[:id]) }\n\n  def index\n    @address_endpoints = @server.address_endpoints.order(:address).to_a\n  end\n\n  def new\n    @address_endpoint = @server.address_endpoints.build\n  end\n\n  def create\n    @address_endpoint = @server.address_endpoints.build(safe_params)\n    if @address_endpoint.save\n      flash[:notice] = params[:return_notice] if params[:return_notice].present?\n      redirect_to_with_json [:return_to, [organization, @server, :address_endpoints]]\n    else\n      render_form_errors \"new\", @address_endpoint\n    end\n  end\n\n  def update\n    if @address_endpoint.update(safe_params)\n      redirect_to_with_json [organization, @server, :address_endpoints]\n    else\n      render_form_errors \"edit\", @address_endpoint\n    end\n  end\n\n  def destroy\n    @address_endpoint.destroy\n    redirect_to_with_json [organization, @server, :address_endpoints]\n  end\n\n  private\n\n  def safe_params\n    params.require(:address_endpoint).permit(:address)\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/application_controller.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"authie/session\"\n\nclass ApplicationController < ActionController::Base\n\n  protect_from_forgery with: :exception\n\n  before_action :login_required\n  before_action :set_timezone\n\n  rescue_from Authie::Session::InactiveSession, with: :auth_session_error\n  rescue_from Authie::Session::ExpiredSession, with: :auth_session_error\n  rescue_from Authie::Session::BrowserMismatch, with: :auth_session_error\n\n  private\n\n  def login_required\n    return if logged_in?\n\n    redirect_to login_path(return_to: request.fullpath)\n  end\n\n  def admin_required\n    if logged_in?\n      unless current_user.admin?\n        render plain: \"Not permitted\"\n      end\n    else\n      redirect_to login_path(return_to: request.fullpath)\n    end\n  end\n\n  def require_organization_owner\n    return if organization.owner == current_user\n\n    redirect_to organization_root_path(organization), alert: \"This page can only be accessed by the organization's owner (#{organization.owner.name})\"\n  end\n\n  def auth_session_error(exception)\n    Rails.logger.info \"AuthSessionError: #{exception.class}: #{exception.message}\"\n    redirect_to login_path(return_to: request.fullpath)\n  end\n\n  def page_title\n    @page_title ||= [\"Postal\"]\n  end\n  helper_method :page_title\n\n  def redirect_to_with_return_to(url, *args)\n    redirect_to url_with_return_to(url), *args\n  end\n\n  def set_timezone\n    Time.zone = logged_in? ? current_user.time_zone : \"UTC\"\n  end\n\n  def append_info_to_payload(payload)\n    super\n    payload[:ip] = request.ip\n    payload[:user] = logged_in? ? current_user.id : nil\n  end\n\n  def url_with_return_to(url)\n    if params[:return_to].blank? || !params[:return_to].starts_with?(\"/\")\n      url_for(url)\n    else\n      params[:return_to]\n    end\n  end\n\n  def redirect_to_with_json(url, flash_messages = {})\n    if url.is_a?(Array) && url[0] == :return_to\n      url = url_with_return_to(url[1])\n    else\n      url = url_for(url)\n    end\n\n    flash_messages.each do |key, value|\n      flash[key] = value\n    end\n    respond_to do |wants|\n      wants.html { redirect_to url }\n      wants.json { render json: { redirect_to: url } }\n    end\n  end\n\n  def render_form_errors(action_name, object)\n    respond_to do |wants|\n      wants.html { render action_name }\n      wants.json { render json: { form_errors: object.errors.map(&:full_message) }, status: :unprocessable_entity }\n    end\n  end\n\n  def flash_now(type, message, options = {})\n    respond_to do |wants|\n      wants.html do\n        flash.now[type] = message\n        if options[:render_action]\n          render options[:render_action]\n        end\n      end\n      wants.json { render json: { flash: { type => message } } }\n    end\n  end\n\n  def login(user)\n    if logged_in?\n      auth_session.invalidate!\n      reset_session\n    end\n\n    create_auth_session(user)\n    @current_user = user\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/concerns/.keep",
    "content": ""
  },
  {
    "path": "app/controllers/concerns/within_organization.rb",
    "content": "# frozen_string_literal: true\n\nmodule WithinOrganization\n\n  extend ActiveSupport::Concern\n\n  included do\n    helper_method :organization\n    before_action :add_organization_to_page_title\n  end\n\n  private\n\n  def organization\n    @organization ||= current_user.organizations_scope.find_by_permalink!(params[:org_permalink])\n  end\n\n  def add_organization_to_page_title\n    page_title << organization.name\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/credentials_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass CredentialsController < ApplicationController\n\n  include WithinOrganization\n\n  before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }\n  before_action { params[:id] && @credential = @server.credentials.find_by_uuid!(params[:id]) }\n\n  def index\n    @credentials = @server.credentials.order(:name).to_a\n  end\n\n  def new\n    @credential = @server.credentials.build\n  end\n\n  def create\n    @credential = @server.credentials.build(params.require(:credential).permit(:type, :name, :key, :hold))\n    if @credential.save\n      redirect_to_with_json [organization, @server, :credentials]\n    else\n      render_form_errors \"new\", @credential\n    end\n  end\n\n  def update\n    if @credential.update(params.require(:credential).permit(:name, :key, :hold))\n      redirect_to_with_json [organization, @server, :credentials]\n    else\n      render_form_errors \"edit\", @credential\n    end\n  end\n\n  def destroy\n    @credential.destroy\n    redirect_to_with_json [organization, @server, :credentials]\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/domains_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass DomainsController < ApplicationController\n\n  include WithinOrganization\n\n  before_action do\n    if params[:server_id]\n      @server = organization.servers.present.find_by_permalink!(params[:server_id])\n      params[:id] && @domain = @server.domains.find_by_uuid!(params[:id])\n    else\n      params[:id] && @domain = organization.domains.find_by_uuid!(params[:id])\n    end\n  end\n\n  def index\n    if @server\n      @domains = @server.domains.order(:name).to_a\n    else\n      @domains = organization.domains.order(:name).to_a\n    end\n  end\n\n  def new\n    @domain = @server ? @server.domains.build : organization.domains.build\n  end\n\n  def create\n    scope = @server ? @server.domains : organization.domains\n    @domain = scope.build(params.require(:domain).permit(:name, :verification_method))\n\n    if current_user.admin?\n      @domain.verification_method = \"DNS\"\n      @domain.verified_at = Time.now\n    end\n\n    if @domain.save\n      if @domain.verified?\n        redirect_to_with_json [:setup, organization, @server, @domain]\n      else\n        redirect_to_with_json [:verify, organization, @server, @domain]\n      end\n    else\n      render_form_errors \"new\", @domain\n    end\n  end\n\n  def destroy\n    @domain.destroy\n    redirect_to_with_json [organization, @server, :domains]\n  end\n\n  def verify\n    if @domain.verified?\n      redirect_to [organization, @server, :domains], alert: \"#{@domain.name} has already been verified.\"\n      return\n    end\n\n    return unless request.post?\n\n    case @domain.verification_method\n    when \"DNS\"\n      if @domain.verify_with_dns\n        redirect_to_with_json [:setup, organization, @server, @domain], notice: \"#{@domain.name} has been verified successfully. You now need to configure your DNS records.\"\n      else\n        respond_to do |wants|\n          wants.html { flash.now[:alert] = \"We couldn't verify your domain. Please double check you've added the TXT record correctly.\" }\n          wants.json { render json: { flash: { alert: \"We couldn't verify your domain. Please double check you've added the TXT record correctly.\" } } }\n        end\n      end\n    when \"Email\"\n      if params[:code]\n        if @domain.verification_token == params[:code].to_s.strip\n          @domain.mark_as_verified\n          redirect_to_with_json [:setup, organization, @server, @domain], notice: \"#{@domain.name} has been verified successfully. You now need to configure your DNS records.\"\n        else\n          respond_to do |wants|\n            wants.html { flash.now[:alert] = \"Invalid verification code. Please check and try again.\" }\n            wants.json { render json: { flash: { alert: \"Invalid verification code. Please check and try again.\" } } }\n          end\n        end\n      elsif params[:email_address].present?\n        raise Postal::Error, \"Invalid email address\" unless @domain.verification_email_addresses.include?(params[:email_address])\n\n        AppMailer.verify_domain(@domain, params[:email_address], current_user).deliver\n        if @domain.owner.is_a?(Server)\n          redirect_to_with_json verify_organization_server_domain_path(organization, @server, @domain, email_address: params[:email_address])\n        else\n          redirect_to_with_json verify_organization_domain_path(organization, @domain, email_address: params[:email_address])\n        end\n      end\n    end\n  end\n\n  def setup\n    return if @domain.verified?\n\n    redirect_to [:verify, organization, @server, @domain], alert: \"You can't set up DNS for this domain until it has been verified.\"\n  end\n\n  def check\n    if @domain.check_dns(:manual)\n      redirect_to_with_json [organization, @server, :domains], notice: \"Your DNS records for #{@domain.name} look good!\"\n    else\n      redirect_to_with_json [:setup, organization, @server, @domain], alert: \"There seems to be something wrong with your DNS records. Check below for information.\"\n    end\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/help_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass HelpController < ApplicationController\n\n  include WithinOrganization\n\n  before_action { @server = organization.servers.find_by_permalink!(params[:server_id]) }\n\n  def outgoing\n    @credentials = @server.credentials.group_by(&:type)\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/http_endpoints_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass HTTPEndpointsController < ApplicationController\n\n  include WithinOrganization\n\n  before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }\n  before_action { params[:id] && @http_endpoint = @server.http_endpoints.find_by_uuid!(params[:id]) }\n\n  def index\n    @http_endpoints = @server.http_endpoints.order(:name).to_a\n  end\n\n  def new\n    @http_endpoint = @server.http_endpoints.build\n  end\n\n  def create\n    @http_endpoint = @server.http_endpoints.build(safe_params)\n    if @http_endpoint.save\n      flash[:notice] = params[:return_notice] if params[:return_notice].present?\n      redirect_to_with_json [:return_to, [organization, @server, :http_endpoints]]\n    else\n      render_form_errors \"new\", @http_endpoint\n    end\n  end\n\n  def update\n    if @http_endpoint.update(safe_params)\n      redirect_to_with_json [organization, @server, :http_endpoints]\n    else\n      render_form_errors \"edit\", @http_endpoint\n    end\n  end\n\n  def destroy\n    @http_endpoint.destroy\n    redirect_to_with_json [organization, @server, :http_endpoints]\n  end\n\n  private\n\n  def safe_params\n    params.require(:http_endpoint).permit(:name, :url, :encoding, :format, :strip_replies, :include_attachments, :timeout)\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/ip_addresses_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass IPAddressesController < ApplicationController\n\n  before_action :admin_required\n  before_action { @ip_pool = IPPool.find_by_uuid!(params[:ip_pool_id]) }\n  before_action { params[:id] && @ip_address = @ip_pool.ip_addresses.find(params[:id]) }\n\n  def new\n    @ip_address = @ip_pool.ip_addresses.build\n  end\n\n  def create\n    @ip_address = @ip_pool.ip_addresses.build(safe_params)\n    if @ip_address.save\n      redirect_to_with_json [:edit, @ip_pool]\n    else\n      render_form_errors \"new\", @ip_address\n    end\n  end\n\n  def update\n    if @ip_address.update(safe_params)\n      redirect_to_with_json [:edit, @ip_pool]\n    else\n      render_form_errors \"edit\", @ip_address\n    end\n  end\n\n  def destroy\n    @ip_address.destroy\n    redirect_to_with_json [:edit, @ip_pool]\n  end\n\n  private\n\n  def safe_params\n    params.require(:ip_address).permit(:ipv4, :ipv6, :hostname, :priority)\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/ip_pool_rules_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass IPPoolRulesController < ApplicationController\n\n  include WithinOrganization\n\n  before_action do\n    if params[:server_id]\n      @server = organization.servers.present.find_by_permalink!(params[:server_id])\n      params[:id] && @ip_pool_rule = @server.ip_pool_rules.find_by_uuid!(params[:id])\n    else\n      params[:id] && @ip_pool_rule = organization.ip_pool_rules.find_by_uuid!(params[:id])\n    end\n  end\n\n  def index\n    if @server\n      @ip_pool_rules = @server.ip_pool_rules\n    else\n      @ip_pool_rules = organization.ip_pool_rules\n    end\n  end\n\n  def new\n    @ip_pool_rule = @server ? @server.ip_pool_rules.build : organization.ip_pool_rules.build\n  end\n\n  def create\n    scope = @server ? @server.ip_pool_rules : organization.ip_pool_rules\n    @ip_pool_rule = scope.build(safe_params)\n    if @ip_pool_rule.save\n      redirect_to_with_json [organization, @server, :ip_pool_rules]\n    else\n      render_form_errors \"new\", @ip_pool_rule\n    end\n  end\n\n  def update\n    if @ip_pool_rule.update(safe_params)\n      redirect_to_with_json [organization, @server, :ip_pool_rules]\n    else\n      render_form_errors \"edit\", @ip_pool_rule\n    end\n  end\n\n  def destroy\n    @ip_pool_rule.destroy\n    redirect_to_with_json [organization, @server, :ip_pool_rules]\n  end\n\n  private\n\n  def safe_params\n    params.require(:ip_pool_rule).permit(:from_text, :to_text, :ip_pool_id)\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/ip_pools_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass IPPoolsController < ApplicationController\n\n  before_action :admin_required\n  before_action { params[:id] && @ip_pool = IPPool.find_by_uuid!(params[:id]) }\n\n  def index\n    @ip_pools = IPPool.order(:name).to_a\n  end\n\n  def new\n    @ip_pool = IPPool.new\n  end\n\n  def create\n    @ip_pool = IPPool.new(safe_params)\n    if @ip_pool.save\n      redirect_to_with_json [:edit, @ip_pool], notice: \"IP Pool has been added successfully. You can now add IP addresses to it.\"\n    else\n      render_form_errors \"new\", @ip_pool\n    end\n  end\n\n  def update\n    if @ip_pool.update(safe_params)\n      redirect_to_with_json [:edit, @ip_pool], notice: \"IP Pool has been updated.\"\n    else\n      render_form_errors \"edit\", @ip_pool\n    end\n  end\n\n  def destroy\n    @ip_pool.destroy\n    redirect_to_with_json :ip_pools, notice: \"IP pool has been removed successfully.\"\n  rescue ActiveRecord::DeleteRestrictionError\n    redirect_to_with_json [:edit, @ip_pool], alert: \"IP pool cannot be removed because it still has associated addresses or servers.\"\n  end\n\n  private\n\n  def safe_params\n    params.require(:ip_pool).permit(:name, :default)\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/legacy_api/base_controller.rb",
    "content": "# frozen_string_literal: true\n\nmodule LegacyAPI\n  # The Legacy API is the Postal v1 API which existed from the start with main\n  # aim of allowing e-mails to sent over HTTP rather than SMTP. The API itself\n  # did not feature much functionality. This API was implemented using Moonrope\n  # which was a self documenting API tool, however, is now no longer maintained.\n  # In light of that, these controllers now implement the same functionality as\n  # the original Moonrope API without the actual requirement to use any of the\n  # Moonrope components.\n  #\n  # Important things to note about the API:\n  #\n  #   * Moonrope allow params to be provided as JSON in the body of the request\n  #     along with the application/json content type. It also allowed for params\n  #     to be sent in the 'params' parameter when using the\n  #     application/x-www-form-urlencoded content type. Both methods are supported.\n  #\n  #   * Authentication is performed using a X-Server-API-Key variable.\n  #\n  #   * The method used to make the request is not important. Most clients use POST\n  #     but other methods should be supported. The routing for this legacvy\n  #     API supports GET, POST, PUT and PATCH.\n  #\n  #   * The status code for responses will always be 200 OK. The actual status of\n  #     a request is determined by the value of the 'status' attribute in the\n  #     returned JSON.\n  class BaseController < ActionController::Base\n\n    skip_before_action :set_browser_id\n    skip_before_action :verify_authenticity_token\n\n    before_action :start_timer\n    before_action :authenticate_as_server\n\n    private\n\n    # The Moonrope API spec allows for parameters to be provided in the body\n    # along with the application/json content type or they can be provided,\n    # as JSON, in the 'params' parameter when used with the\n    # application/x-www-form-urlencoded content type. This legacy API needs\n    # support both options for maximum compatibility.\n    #\n    # @return [Hash]\n    def api_params\n      if request.headers[\"content-type\"] =~ /\\Aapplication\\/json/\n        return params.to_unsafe_hash\n      end\n\n      if params[\"params\"].present?\n        return JSON.parse(params[\"params\"])\n      end\n\n      {}\n    end\n\n    # The API returns a length of time to complete a request. We'll start\n    # a timer when the request starts and then use this method to calculate\n    # the time taken to complete the request.\n    #\n    # @return [void]\n    def start_timer\n      @start_time = Time.now.to_f\n    end\n\n    # The only method available to authenticate to the legacy API is using a\n    # credential from the server itself. This method will attempt to find\n    # that credential from the X-Server-API-Key header and will set the\n    # current_credential instance variable if a token is valid. Otherwise it\n    # will render an error to halt execution.\n    #\n    # @return [void]\n    def authenticate_as_server\n      key = request.headers[\"X-Server-API-Key\"]\n      if key.blank?\n        render_error \"AccessDenied\",\n                     message: \"Must be authenticated as a server.\"\n        return\n      end\n\n      credential = Credential.where(type: \"API\", key: key).first\n      if credential.nil?\n        render_error \"InvalidServerAPIKey\",\n                     message: \"The API token provided in X-Server-API-Key was not valid.\",\n                     token: key\n        return\n      end\n\n      if credential.server.suspended?\n        render_error \"ServerSuspended\"\n        return\n      end\n\n      credential.use\n      @current_credential = credential\n    end\n\n    # Render a successful response to the client\n    #\n    # @param [Hash] data\n    # @return [void]\n    def render_success(data)\n      render json: { status: \"success\",\n                     time: (Time.now.to_f - @start_time).round(3),\n                     flags: {},\n                     data: data }\n    end\n\n    # Render an error response to the client\n    #\n    # @param [String] code\n    # @param [Hash] data\n    # @return [void]\n    def render_error(code, data = {})\n      render json: { status: \"error\",\n                     time: (Time.now.to_f - @start_time).round(3),\n                     flags: {},\n                     data: data.merge(code: code) }\n    end\n\n    # Render a parameter error response to the client\n    #\n    # @param [String] message\n    # @return [void]\n    def render_parameter_error(message)\n      render json: { status: \"parameter-error\",\n                     time: (Time.now.to_f - @start_time).round(3),\n                     flags: {},\n                     data: { message: message } }\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/controllers/legacy_api/messages_controller.rb",
    "content": "# frozen_string_literal: true\n\nmodule LegacyAPI\n  class MessagesController < BaseController\n\n    # Returns details about a given message\n    #\n    #   URL:            /api/v1/messages/message\n    #\n    #   Parameters:     id              => REQ: The ID of the message\n    #                   _expansions     => An array of types of details t\n    #                                      to return\n    #\n    #   Response:       A hash containing message information\n    #                   OR an error if the message does not exist.\n    #\n    def message\n      if api_params[\"id\"].blank?\n        render_parameter_error \"`id` parameter is required but is missing\"\n        return\n      end\n\n      message = @current_credential.server.message(api_params[\"id\"])\n      message_hash = { id: message.id, token: message.token }\n      expansions = api_params[\"_expansions\"]\n\n      if expansions == true || (expansions.is_a?(Array) && expansions.include?(\"status\"))\n        message_hash[:status] = {\n          status: message.status,\n          last_delivery_attempt: message.last_delivery_attempt&.to_f,\n          held: message.held,\n          hold_expiry: message.hold_expiry&.to_f\n        }\n      end\n\n      if expansions == true || (expansions.is_a?(Array) && expansions.include?(\"details\"))\n        message_hash[:details] = {\n          rcpt_to: message.rcpt_to,\n          mail_from: message.mail_from,\n          subject: message.subject,\n          message_id: message.message_id,\n          timestamp: message.timestamp.to_f,\n          direction: message.scope,\n          size: message.size,\n          bounce: message.bounce,\n          bounce_for_id: message.bounce_for_id,\n          tag: message.tag,\n          received_with_ssl: message.received_with_ssl\n        }\n      end\n\n      if expansions == true || (expansions.is_a?(Array) && expansions.include?(\"inspection\"))\n        message_hash[:inspection] = {\n          inspected: message.inspected,\n          spam: message.spam,\n          spam_score: message.spam_score.to_f,\n          threat: message.threat,\n          threat_details: message.threat_details\n        }\n      end\n\n      if expansions == true || (expansions.is_a?(Array) && expansions.include?(\"plain_body\"))\n        message_hash[:plain_body] = message.plain_body\n      end\n\n      if expansions == true || (expansions.is_a?(Array) && expansions.include?(\"html_body\"))\n        message_hash[:html_body] = message.html_body\n      end\n\n      if expansions == true || (expansions.is_a?(Array) && expansions.include?(\"attachments\"))\n        message_hash[:attachments] = message.attachments.map do |attachment|\n          {\n            filename: attachment.filename.to_s,\n            content_type: attachment.mime_type,\n            data: Base64.encode64(attachment.body.to_s),\n            size: attachment.body.to_s.bytesize,\n            hash: Digest::SHA1.hexdigest(attachment.body.to_s)\n          }\n        end\n      end\n\n      if expansions == true || (expansions.is_a?(Array) && expansions.include?(\"headers\"))\n        message_hash[:headers] = message.headers\n      end\n\n      if expansions == true || (expansions.is_a?(Array) && expansions.include?(\"raw_message\"))\n        message_hash[:raw_message] = Base64.encode64(message.raw_message)\n      end\n\n      if expansions == true || (expansions.is_a?(Array) && expansions.include?(\"activity_entries\"))\n        message_hash[:activity_entries] = {\n          loads: message.loads,\n          clicks: message.clicks\n        }\n      end\n\n      render_success message_hash\n    rescue Postal::MessageDB::Message::NotFound\n      render_error \"MessageNotFound\",\n                   message: \"No message found matching provided ID\",\n                   id: api_params[\"id\"]\n    end\n\n    # Returns all the deliveries for a given message\n    #\n    #   URL:            /api/v1/messages/deliveries\n    #\n    #   Parameters:     id              => REQ: The ID of the message\n    #\n    #   Response:       A array of hashes containing delivery information\n    #                   OR an error if the message does not exist.\n    #\n    def deliveries\n      if api_params[\"id\"].blank?\n        render_parameter_error \"`id` parameter is required but is missing\"\n        return\n      end\n\n      message = @current_credential.server.message(api_params[\"id\"])\n      deliveries = message.deliveries.map do |d|\n        {\n          id: d.id,\n          status: d.status,\n          details: d.details,\n          output: d.output&.strip,\n          sent_with_ssl: d.sent_with_ssl,\n          log_id: d.log_id,\n          time: d.time&.to_f,\n          timestamp: d.timestamp.to_f\n        }\n      end\n      render_success deliveries\n    rescue Postal::MessageDB::Message::NotFound\n      render_error \"MessageNotFound\",\n                   message: \"No message found matching provided ID\",\n                   id: api_params[\"id\"]\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/controllers/legacy_api/send_controller.rb",
    "content": "# frozen_string_literal: true\n\nmodule LegacyAPI\n  class SendController < BaseController\n\n    ERROR_MESSAGES = {\n      \"NoRecipients\" => \"There are no recipients defined to receive this message\",\n      \"NoContent\" => \"There is no content defined for this e-mail\",\n      \"TooManyToAddresses\" => \"The maximum number of To addresses has been reached (maximum 50)\",\n      \"TooManyCCAddresses\" => \"The maximum number of CC addresses has been reached (maximum 50)\",\n      \"TooManyBCCAddresses\" => \"The maximum number of BCC addresses has been reached (maximum 50)\",\n      \"FromAddressMissing\" => \"The From address is missing and is required\",\n      \"UnauthenticatedFromAddress\" => \"The From address is not authorised to send mail from this server\",\n      \"AttachmentMissingName\" => \"An attachment is missing a name\",\n      \"AttachmentMissingData\" => \"An attachment is missing data\"\n    }.freeze\n\n    # Send a message with the given options\n    #\n    #   URL:            /api/v1/send/message\n    #\n    #   Parameters:     to              => REQ: An array of emails addresses\n    #                   cc              => An array of email addresses to CC\n    #                   bcc             => An array of email addresses to BCC\n    #                   from            => The name/email to send the email from\n    #                   sender          => The name/email of the 'Sender'\n    #                   reply_to        => The name/email of the 'Reply-to'\n    #                   plain_body      => The plain body\n    #                   html_body       => The HTML body\n    #                   bounce          => Is this message a bounce?\n    #                   tag             => A custom tag to add to the message\n    #                   custom_headers  => A hash of custom headers\n    #                   attachments     => An array of attachments\n    #                                      (name, content_type and data (base64))\n    #\n    #   Response:       A array of hashes containing message information\n    #                   OR an error if there is an issue sending the message\n    #\n    def message\n      attributes = {}\n      attributes[:to] = api_params[\"to\"]\n      attributes[:cc] = api_params[\"cc\"]\n      attributes[:bcc] = api_params[\"bcc\"]\n      attributes[:from] = api_params[\"from\"]\n      attributes[:sender] = api_params[\"sender\"]\n      attributes[:subject] = api_params[\"subject\"]\n      attributes[:reply_to] = api_params[\"reply_to\"]\n      attributes[:plain_body] = api_params[\"plain_body\"]\n      attributes[:html_body] = api_params[\"html_body\"]\n      attributes[:bounce] = api_params[\"bounce\"] ? true : false\n      attributes[:tag] = api_params[\"tag\"]\n      attributes[:custom_headers] = api_params[\"headers\"] if api_params[\"headers\"]\n      attributes[:attachments] = []\n\n      (api_params[\"attachments\"] || []).each do |attachment|\n        next unless attachment.is_a?(Hash)\n\n        attributes[:attachments] << { name: attachment[\"name\"], content_type: attachment[\"content_type\"], data: attachment[\"data\"], base64: true }\n      end\n\n      message = OutgoingMessagePrototype.new(@current_credential.server, request.ip, \"api\", attributes)\n      message.credential = @current_credential\n      if message.valid?\n        result = message.create_messages\n        render_success message_id: message.message_id, messages: result\n      else\n        render_error message.errors.first, message: ERROR_MESSAGES[message.errors.first]\n      end\n    end\n\n    # Send a message by providing a raw message\n    #\n    #   URL:            /api/v1/send/raw\n    #\n    #   Parameters:     rcpt_to         => REQ: An array of email addresses to send\n    #                                      the message to\n    #                   mail_from       => REQ: the address to send the email from\n    #                   data            => REQ: base64-encoded mail data\n    #\n    #   Response:       A array of hashes containing message information\n    #                   OR an error if there is an issue sending the message\n    #\n    def raw\n      unless api_params[\"rcpt_to\"].is_a?(Array)\n        render_parameter_error \"`rcpt_to` parameter is required but is missing\"\n        return\n      end\n\n      if api_params[\"mail_from\"].blank?\n        render_parameter_error \"`mail_from` parameter is required but is missing\"\n        return\n      end\n\n      if api_params[\"data\"].blank?\n        render_parameter_error \"`data` parameter is required but is missing\"\n        return\n      end\n\n      # Decode the raw message\n      raw_message = Base64.decode64(api_params[\"data\"])\n\n      # Parse through mail to get the from/sender headers\n      mail = Mail.new(raw_message.split(\"\\r\\n\\r\\n\", 2).first)\n      from_headers = { \"from\" => mail.from, \"sender\" => mail.sender }\n      authenticated_domain = @current_credential.server.find_authenticated_domain_from_headers(from_headers)\n\n      # If we're not authenticated, don't continue\n      if authenticated_domain.nil?\n        render_error \"UnauthenticatedFromAddress\"\n        return\n      end\n\n      # Store the result ready to return\n      result = { message_id: nil, messages: {} }\n      if api_params[\"rcpt_to\"].is_a?(Array)\n        api_params[\"rcpt_to\"].uniq.each do |rcpt_to|\n          message = @current_credential.server.message_db.new_message\n          message.rcpt_to = rcpt_to\n          message.mail_from = api_params[\"mail_from\"]\n          message.raw_message = raw_message\n          message.received_with_ssl = true\n          message.scope = \"outgoing\"\n          message.domain_id = authenticated_domain.id\n          message.credential_id = @current_credential.id\n          message.bounce = api_params[\"bounce\"] ? true : false\n          message.save\n          result[:message_id] = message.message_id if result[:message_id].nil?\n          result[:messages][rcpt_to] = { id: message.id, token: message.token }\n        end\n      end\n      render_success result\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/controllers/messages_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass MessagesController < ApplicationController\n\n  include WithinOrganization\n\n  before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }\n  before_action { params[:id] && @message = @server.message_db.message(params[:id].to_i) }\n\n  def new\n    if params[:direction] == \"incoming\"\n      @message = IncomingMessagePrototype.new(@server, request.ip, \"web-ui\", {})\n      @message.from = session[:test_in_from] || current_user.email_tag\n      @message.to = @server.routes.order(:name).first&.description\n    else\n      @message = OutgoingMessagePrototype.new(@server, request.ip, \"web-ui\", {})\n      @message.to = session[:test_out_to] || current_user.email_address\n      if domain = @server.domains.verified.order(:name).first\n        @message.from = \"test@#{domain.name}\"\n      end\n    end\n    @message.subject = \"Test Message at #{Time.zone.now.to_fs(:long)}\"\n    @message.plain_body = \"This is a message to test the delivery of messages through Postal.\"\n  end\n\n  def create\n    if params[:direction] == \"incoming\"\n      session[:test_in_from] = params[:message][:from] if params[:message]\n      @message = IncomingMessagePrototype.new(@server, request.ip, \"web-ui\", params[:message])\n      @message.attachments = [{ name: \"test.txt\", content_type: \"text/plain\", data: \"Hello world!\" }]\n    else\n      session[:test_out_to] = params[:message][:to] if params[:message]\n      @message = OutgoingMessagePrototype.new(@server, request.ip, \"web-ui\", params[:message])\n    end\n    if result = @message.create_messages\n      if result.size == 1\n        redirect_to_with_json organization_server_message_path(organization, @server, result.first.last[:id]), notice: \"Message was queued successfully\"\n      else\n        redirect_to_with_json [:queue, organization, @server], notice: \"Messages queued successfully \"\n      end\n    else\n      respond_to do |wants|\n        wants.html do\n          flash.now[:alert] = \"Your message could not be sent. Ensure that all fields are completed fully. #{result.errors.inspect}\"\n          render \"new\"\n        end\n        wants.json do\n          render json: { flash: { alert: \"Your message could not be sent. Please check all field are completed fully.\" } }\n        end\n      end\n\n    end\n  end\n\n  def outgoing\n    @searchable = true\n    get_messages(\"outgoing\")\n    respond_to do |wants|\n      wants.html\n      wants.json do\n        render json: {\n          flash: flash.each_with_object({}) { |(type, message), hash| hash[type] = message },\n          region_html: render_to_string(partial: \"index\", formats: [:html])\n        }\n      end\n    end\n  end\n\n  def incoming\n    @searchable = true\n    get_messages(\"incoming\")\n    respond_to do |wants|\n      wants.html\n      wants.json do\n        render json: {\n          flash: flash.each_with_object({}) { |(type, message), hash| hash[type] = message },\n          region_html: render_to_string(partial: \"index\", formats: [:html])\n        }\n      end\n    end\n  end\n\n  def held\n    get_messages(\"held\")\n  end\n\n  def deliveries\n    render json: { html: render_to_string(partial: \"deliveries\", locals: { message: @message }) }\n  end\n\n  def html_raw\n    render html: @message.html_body_without_tracking_image.html_safe\n  end\n\n  def spam_checks\n    @spam_checks = @message.spam_checks.sort_by { |s| s[\"score\"] }.reverse\n  end\n\n  def attachment\n    if @message.attachments.size > params[:attachment].to_i\n      attachment = @message.attachments[params[:attachment].to_i]\n      send_data attachment.body, content_type: attachment.mime_type, disposition: \"download\", filename: attachment.filename\n    else\n      redirect_to attachments_organization_server_message_path(organization, @server, @message.id), alert: \"Attachment not found. Choose an attachment from the list below.\"\n    end\n  end\n\n  def download\n    if @message.raw_message\n      send_data @message.raw_message, filename: \"Message-#{organization.permalink}-#{@server.permalink}-#{@message.id}.eml\", content_type: \"text/plain\"\n    else\n      redirect_to organization_server_message_path(organization, @server, @message.id), alert: \"We no longer have the raw message stored for this message.\"\n    end\n  end\n\n  def retry\n    if @message.raw_message?\n      if @message.queued_message\n        @message.queued_message.retry_now\n        flash[:notice] = \"This message will be retried shortly.\"\n      elsif @message.held?\n        @message.add_to_message_queue(manual: true)\n        flash[:notice] = \"This message has been released. Delivery will be attempted shortly.\"\n      else\n        @message.add_to_message_queue(manual: true)\n        flash[:notice] = \"This message will be redelivered shortly.\"\n      end\n    else\n      flash[:alert] = \"This message is no longer available.\"\n    end\n    redirect_to_with_json organization_server_message_path(organization, @server, @message.id)\n  end\n\n  def cancel_hold\n    @message.cancel_hold\n    redirect_to_with_json organization_server_message_path(organization, @server, @message.id)\n  end\n\n  def remove_from_queue\n    if @message.queued_message && !@message.queued_message.locked?\n      @message.queued_message.destroy\n    end\n    redirect_to_with_json organization_server_message_path(organization, @server, @message.id)\n  end\n\n  def suppressions\n    @suppressions = @server.message_db.suppression_list.all_with_pagination(params[:page])\n  end\n\n  def activity\n    @entries = @message.activity_entries\n  end\n\n  private\n\n  def get_messages(scope)\n    if scope == \"held\"\n      options = { where: { held: true } }\n    else\n      options = { where: { scope: scope, spam: false }, order: :timestamp, direction: \"desc\" }\n\n      if @query = (params[:query] || session[\"msg_query_#{@server.id}_#{scope}\"]).presence\n        session[\"msg_query_#{@server.id}_#{scope}\"] = @query\n        qs = QueryString.new(@query)\n        if qs.empty?\n          flash.now[:alert] = \"It doesn't appear you entered anything to filter on. Please double check your query.\"\n        else\n          @queried = true\n          if qs[:order] == \"oldest-first\"\n            options[:direction] = \"asc\"\n          end\n\n          options[:where][:rcpt_to] = qs[:to] if qs[:to]\n          options[:where][:mail_from] = qs[:from] if qs[:from]\n          options[:where][:status] = qs[:status] if qs[:status]\n          options[:where][:token] = qs[:token] if qs[:token]\n\n          if qs[:msgid]\n            options[:where][:message_id] = qs[:msgid]\n            options[:where].delete(:spam)\n            options[:where].delete(:scope)\n          end\n          options[:where][:tag] = qs[:tag] if qs[:tag]\n          options[:where][:id] = qs[:id] if qs[:id]\n          options[:where][:spam] = true if qs[:spam] == \"yes\" || qs[:spam] == \"y\"\n          if qs[:before] || qs[:after]\n            options[:where][:timestamp] = {}\n            if qs[:before]\n              begin\n                options[:where][:timestamp][:less_than] = get_time_from_string(qs[:before]).to_f\n              rescue TimeUndetermined\n                flash.now[:alert] = \"Couldn't determine time for before from '#{qs[:before]}'\"\n              end\n            end\n\n            if qs[:after]\n              begin\n                options[:where][:timestamp][:greater_than] = get_time_from_string(qs[:after]).to_f\n              rescue TimeUndetermined\n                flash.now[:alert] = \"Couldn't determine time for after from '#{qs[:after]}'\"\n              end\n            end\n          end\n        end\n      else\n        session[\"msg_query_#{@server.id}_#{scope}\"] = nil\n      end\n    end\n\n    @messages = @server.message_db.messages_with_pagination(params[:page], options)\n  end\n\n  class TimeUndetermined < Postal::Error; end\n\n  def get_time_from_string(string)\n    begin\n      if string =~ /\\A(\\d{2,4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2})\\z/\n        time = Time.new(::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i, ::Regexp.last_match(4).to_i, ::Regexp.last_match(5).to_i)\n      elsif string =~ /\\A(\\d{2,4})-(\\d{2})-(\\d{2})\\z/\n        time = Time.new(::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i, 0)\n      else\n        time = Chronic.parse(string, context: :past)\n      end\n    rescue StandardError\n      time = nil\n    end\n\n    raise TimeUndetermined, \"Couldn't determine a suitable time from '#{string}'\" if time.nil?\n\n    time\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/organization_ip_pools_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass OrganizationIPPoolsController < ApplicationController\n\n  include WithinOrganization\n  before_action :admin_required, only: [:assignments]\n\n  def index\n    @ip_pools = organization.ip_pools.order(:name)\n  end\n\n  def assignments\n    organization.ip_pool_ids = params[:ip_pools]\n    organization.save!\n    redirect_to [organization, :ip_pools], notice: \"Organization IP pools have been updated successfully\"\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/organizations_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass OrganizationsController < ApplicationController\n\n  before_action :admin_required, only: [:new, :create, :delete, :destroy]\n\n  def index\n    if current_user.admin?\n      @organizations = Organization.present.order(:name).to_a\n    else\n      @organizations = current_user.organizations.present.order(:name).to_a\n      if @organizations.size == 1 && params[:nrd].nil?\n        redirect_to organization_root_path(@organizations.first)\n      end\n    end\n  end\n\n  def new\n    @organization = Organization.new\n  end\n\n  def edit\n    @organization_obj = current_user.organizations_scope.find(organization.id)\n  end\n\n  def create\n    @organization = Organization.new(params.require(:organization).permit(:name, :permalink))\n    @organization.owner = current_user\n    if @organization.save\n      redirect_to_with_json organization_root_path(@organization)\n    else\n      render_form_errors \"new\", @organization\n    end\n  end\n\n  def update\n    @organization_obj = current_user.organizations_scope.find(organization.id)\n    if @organization_obj.update(params.require(:organization).permit(:name, :time_zone))\n      redirect_to_with_json organization_settings_path(@organization_obj), notice: \"Settings for #{@organization_obj.name} have been saved successfully.\"\n    else\n      render_form_errors \"edit\", @organization_obj\n    end\n  end\n\n  def destroy\n    if params[:confirm_text].blank? || params[:confirm_text].downcase.strip != organization.name.downcase.strip\n      respond_to do |wants|\n        alert_text = \"The text you entered does not match the organization name. Please check and try again.\"\n        wants.html { redirect_to organization_delete_path(@organization), alert: alert_text }\n        wants.json { render json: { alert: alert_text } }\n      end\n      return\n    end\n\n    organization.soft_destroy\n    redirect_to_with_json root_path(nrd: 1), notice: \"#{@organization.name} has been removed successfully.\"\n  end\n\n  private\n\n  def organization\n    return unless [:edit, :update, :delete, :destroy].include?(action_name.to_sym)\n\n    @organization ||= params[:org_permalink] ? current_user.organizations_scope.find_by_permalink!(params[:org_permalink]) : nil\n  end\n  helper_method :organization\n\nend\n"
  },
  {
    "path": "app/controllers/routes_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass RoutesController < ApplicationController\n\n  include WithinOrganization\n\n  before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }\n  before_action { params[:id] && @route = @server.routes.find_by_uuid!(params[:id]) }\n\n  def index\n    @routes = @server.routes.order(:name).includes(:domain, :endpoint).to_a\n  end\n\n  def new\n    @route = @server.routes.build\n  end\n\n  def create\n    @route = @server.routes.build(safe_params)\n    if @route.save\n      redirect_to_with_json [organization, @server, :routes]\n    else\n      render_form_errors \"new\", @route\n    end\n  end\n\n  def update\n    if @route.update(safe_params)\n      redirect_to_with_json [organization, @server, :routes]\n    else\n      render_form_errors \"edit\", @route\n    end\n  end\n\n  def destroy\n    @route.destroy\n    redirect_to_with_json [organization, @server, :routes]\n  end\n\n  private\n\n  def safe_params\n    params.require(:route).permit(:name, :domain_id, :spam_mode, :_endpoint, additional_route_endpoints_array: [])\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/servers_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass ServersController < ApplicationController\n\n  include WithinOrganization\n\n  before_action :admin_required, only: [:advanced, :suspend, :unsuspend]\n  before_action { params[:id] && @server = organization.servers.present.find_by_permalink!(params[:id]) }\n\n  def index\n    @servers = organization.servers.present.order(:name).to_a\n  end\n\n  def show\n    if @server.created_at < 48.hours.ago\n      @graph_type = :daily\n      graph_data = @server.message_db.statistics.get(:daily, [:incoming, :outgoing, :bounces], Time.now, 30)\n    elsif @server.created_at < 24.hours.ago\n      @graph_type = :hourly\n      graph_data = @server.message_db.statistics.get(:hourly, [:incoming, :outgoing, :bounces], Time.now, 48)\n    else\n      @graph_type = :hourly\n      graph_data = @server.message_db.statistics.get(:hourly, [:incoming, :outgoing, :bounces], Time.now, 24)\n    end\n    @first_date = graph_data.first.first\n    @last_date = graph_data.last.first\n    @graph_data = graph_data.map(&:last)\n    @messages = @server.message_db.messages(order: \"id\", direction: \"desc\", limit: 6)\n  end\n\n  def new\n    @server = organization.servers.build\n  end\n\n  def create\n    @server = organization.servers.build(safe_params(:permalink))\n    if @server.save\n      redirect_to_with_json organization_server_path(organization, @server)\n    else\n      render_form_errors \"new\", @server\n    end\n  end\n\n  def update\n    extra_params = [:spam_threshold, :spam_failure_threshold, :postmaster_address]\n\n    if current_user.admin?\n      extra_params += [\n        :send_limit,\n        :allow_sender,\n        :privacy_mode,\n        :log_smtp_data,\n        :outbound_spam_threshold,\n        :message_retention_days,\n        :raw_message_retention_days,\n        :raw_message_retention_size,\n      ]\n    end\n\n    if @server.update(safe_params(*extra_params))\n      redirect_to_with_json organization_server_path(organization, @server), notice: \"Server settings have been updated\"\n    else\n      render_form_errors \"edit\", @server\n    end\n  end\n\n  def destroy\n    if params[:confirm_text].blank? || params[:confirm_text].downcase.strip != @server.name.downcase.strip\n      respond_to do |wants|\n        alert_text = \"The text you entered does not match the server name. Please check and try again.\"\n        wants.html { redirect_to organization_delete_path(@organization), alert: alert_text }\n        wants.json { render json: { alert: alert_text } }\n      end\n      return\n    end\n\n    @server.soft_destroy\n    redirect_to_with_json organization_root_path(organization), notice: \"#{@server.name} has been deleted successfully\"\n  end\n\n  def queue\n    @messages = @server.queued_messages.order(id: :desc).page(params[:page]).includes(:ip_address)\n    @messages_with_message = @messages.include_message\n  end\n\n  def suspend\n    @server.suspend(params[:reason])\n    redirect_to_with_json [organization, @server], notice: \"Server has been suspended\"\n  end\n\n  def unsuspend\n    @server.unsuspend\n    redirect_to_with_json [organization, @server], notice: \"Server has been unsuspended\"\n  end\n\n  private\n\n  def safe_params(*extras)\n    params.require(:server).permit(:name, :mode, :ip_pool_id, *extras)\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/sessions_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass SessionsController < ApplicationController\n\n  layout \"sub\"\n\n  before_action :require_local_authentication, only: [:create, :begin_password_reset, :finish_password_reset]\n  skip_before_action :login_required, only: [:new, :create, :begin_password_reset, :finish_password_reset, :ip, :raise_error, :create_from_oidc, :oauth_failure]\n\n  def create\n    login(User.authenticate(params[:email_address], params[:password]))\n    flash[:remember_login] = true\n    redirect_to_with_return_to root_path\n  rescue Postal::Errors::AuthenticationError\n    flash.now[:alert] = \"The credentials you've provided are incorrect. Please check and try again.\"\n    render \"new\"\n  end\n\n  def destroy\n    auth_session.invalidate! if logged_in?\n    reset_session\n    redirect_to login_path\n  end\n\n  def persist\n    auth_session.persist! if logged_in?\n    render plain: \"OK\"\n  end\n\n  def begin_password_reset\n    return unless request.post?\n\n    user_scope = Postal::Config.oidc.enabled? ? User.with_password : User\n    user = user_scope.find_by(email_address: params[:email_address])\n\n    if user.nil?\n      redirect_to login_reset_path(return_to: params[:return_to]), alert: \"No local user exists with that e-mail address. Please check and try again.\"\n      return\n    end\n\n    user.begin_password_reset(params[:return_to])\n    redirect_to login_path(return_to: params[:return_to]), notice: \"Please check your e-mail and click the link in the e-mail we've sent you.\"\n  end\n\n  def finish_password_reset\n    @user = User.where(password_reset_token: params[:token]).where(\"password_reset_token_valid_until > ?\", Time.now).first\n    if @user.nil?\n      redirect_to login_path(return_to: params[:return_to]), alert: \"This link has expired or never existed. Please choose reset password to try again.\"\n    end\n\n    return unless request.post?\n\n    if params[:password].blank?\n      flash.now[:alert] = \"You must enter a new password\"\n      return\n    end\n\n    @user.password = params[:password]\n    @user.password_confirmation = params[:password_confirmation]\n    return unless @user.save\n\n    login(@user)\n    redirect_to_with_return_to root_path, notice: \"Your new password has been set and you've been logged in.\"\n  end\n\n  def ip\n    render plain: \"ip: #{request.ip} remote ip: #{request.remote_ip}\"\n  end\n\n  def create_from_oidc\n    unless Postal::Config.oidc.enabled?\n      raise Postal::Error, \"OIDC cannot be used unless enabled in the configuration\"\n    end\n\n    auth = request.env[\"omniauth.auth\"]\n    user = User.find_from_oidc(auth.extra.raw_info, logger: Postal.logger)\n    if user.nil?\n      redirect_to login_path, alert: \"No user was found matching your identity. Please contact your administrator.\"\n      return\n    end\n\n    login(user)\n    flash[:remember_login] = true\n    redirect_to_with_return_to root_path\n  end\n\n  def oauth_failure\n    redirect_to login_path, alert: \"An issue occurred while logging you in with OpenID. Please try again later or contact your administrator.\"\n  end\n\n  private\n\n  def require_local_authentication\n    return if Postal::Config.oidc.local_authentication_enabled?\n\n    redirect_to login_path, alert: \"Local authentication is not enabled\"\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/smtp_endpoints_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass SMTPEndpointsController < ApplicationController\n\n  include WithinOrganization\n  before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }\n  before_action { params[:id] && @smtp_endpoint = @server.smtp_endpoints.find_by_uuid!(params[:id]) }\n\n  def index\n    @smtp_endpoints = @server.smtp_endpoints.order(:name).to_a\n  end\n\n  def new\n    @smtp_endpoint = @server.smtp_endpoints.build\n  end\n\n  def create\n    @smtp_endpoint = @server.smtp_endpoints.build(safe_params)\n    if @smtp_endpoint.save\n      flash[:notice] = params[:return_notice] if params[:return_notice].present?\n      redirect_to_with_json [:return_to, [organization, @server, :smtp_endpoints]]\n    else\n      render_form_errors \"new\", @smtp_endpoint\n    end\n  end\n\n  def update\n    if @smtp_endpoint.update(safe_params)\n      redirect_to_with_json [organization, @server, :smtp_endpoints]\n    else\n      render_form_errors \"edit\", @smtp_endpoint\n    end\n  end\n\n  def destroy\n    @smtp_endpoint.destroy\n    redirect_to_with_json [organization, @server, :smtp_endpoints]\n  end\n\n  private\n\n  def safe_params\n    params.require(:smtp_endpoint).permit(:name, :hostname, :port, :ssl_mode)\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/track_domains_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass TrackDomainsController < ApplicationController\n\n  include WithinOrganization\n  before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }\n  before_action { params[:id] && @track_domain = @server.track_domains.find_by_uuid!(params[:id]) }\n\n  def index\n    @track_domains = @server.track_domains.order(:name).to_a\n  end\n\n  def new\n    @track_domain = @server.track_domains.build\n  end\n\n  def create\n    @track_domain = @server.track_domains.build(params.require(:track_domain).permit(:name, :domain_id, :track_loads, :track_clicks, :excluded_click_domains, :ssl_enabled))\n    if @track_domain.save\n      redirect_to_with_json [:return_to, [organization, @server, :track_domains]]\n    else\n      render_form_errors \"new\", @track_domain\n    end\n  end\n\n  def update\n    if @track_domain.update(params.require(:track_domain).permit(:track_loads, :track_clicks, :excluded_click_domains, :ssl_enabled))\n      redirect_to_with_json [organization, @server, :track_domains]\n    else\n      render_form_errors \"edit\", @track_domain\n    end\n  end\n\n  def destroy\n    @track_domain.destroy\n    redirect_to_with_json [organization, @server, :track_domains]\n  end\n\n  def check\n    if @track_domain.check_dns\n      redirect_to_with_json [organization, @server, :track_domains], notice: \"Your CNAME for #{@track_domain.full_name} looks good!\"\n    else\n      redirect_to_with_json [organization, @server, :track_domains], alert: \"There seems to be something wrong with your DNS record. Check documentation for information.\"\n    end\n  end\n\n  def toggle_ssl\n    @track_domain.update(ssl_enabled: !@track_domain.ssl_enabled)\n    redirect_to_with_json [organization, @server, :track_domains], notice: \"SSL settings for #{@track_domain.full_name} updated successfully.\"\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/user_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass UserController < ApplicationController\n\n  skip_before_action :login_required, only: [:new, :create, :join]\n\n  def new\n    @user_invite = UserInvite.active.find_by!(uuid: params[:invite_token])\n    @user = User.new\n    @user.email_address = @user_invite.email_address\n    render layout: \"sub\"\n  end\n\n  def edit\n    @user = User.find(current_user.id)\n  end\n\n  def create\n    @user_invite = UserInvite.active.find_by!(uuid: params[:invite_token])\n    @user = User.new(params.require(:user).permit(:first_name, :last_name, :email_address, :password, :password_confirmation))\n    @user.email_verified_at = Time.now\n    if @user.save\n      @user_invite.accept(@user)\n      self.current_user = @user\n      redirect_to root_path\n    else\n      render \"new\", layout: \"sub\"\n    end\n  end\n\n  def update\n    @user = User.find(current_user.id)\n    safe_params = [:first_name, :last_name, :time_zone, :email_address]\n\n    if @user.password? && Postal::Config.oidc.local_authentication_enabled?\n      safe_params += [:password, :password_confirmation]\n      if @user.authenticate_with_previous_password_first(params[:password])\n        @password_correct = true\n      else\n        respond_to do |wants|\n          wants.html do\n            flash.now[:alert] = \"The current password you have entered is incorrect. Please check and try again.\"\n            render \"edit\"\n          end\n          wants.json do\n            render json: { alert: \"The current password you've entered is incorrect. Please check and try again\" }\n          end\n        end\n        return\n      end\n    end\n\n    @user.attributes = params.require(:user).permit(safe_params)\n\n    if @user.save\n      redirect_to_with_json settings_path, notice: \"Your settings have been updated successfully.\"\n    else\n      render_form_errors \"edit\", @user\n    end\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/users_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass UsersController < ApplicationController\n\n  before_action :admin_required\n  before_action { params[:id] && @user = User.find_by!(uuid: params[:id]) }\n\n  def index\n    @users = User.order(:first_name, :last_name).includes(:organization_users)\n  end\n\n  def new\n    @user = User.new(admin: true)\n  end\n\n  def edit\n  end\n\n  def create\n    @user = User.new(params.require(:user).permit(:email_address, :first_name, :last_name, :password, :password_confirmation, :admin, organization_ids: []))\n    if @user.save\n      redirect_to_with_json :users, notice: \"#{@user.name} has been created successfully.\"\n    else\n      render_form_errors \"new\", @user\n    end\n  end\n\n  def update\n    @user.attributes = params.require(:user).permit(:email_address, :first_name, :last_name, :admin, organization_ids: [])\n\n    if @user == current_user && !@user.admin?\n      respond_to do |wants|\n        wants.html { redirect_to users_path, alert: \"You cannot change your own admin status\" }\n        wants.json { render json: { form_errors: [\"You cannot change your own admin status\"] }, status: :unprocessable_entity }\n      end\n      return\n    end\n\n    if @user.save\n      redirect_to_with_json :users, notice: \"Permissions for #{@user.name} have been updated successfully.\"\n    else\n      render_form_errors \"edit\", @user\n    end\n  end\n\n  def destroy\n    if @user == current_user\n      redirect_to_with_json :users, alert: \"You cannot delete your own user.\"\n      return\n    end\n\n    @user.destroy!\n    redirect_to_with_json :users, notice: \"#{@user.name} has been removed\"\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/webhooks_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass WebhooksController < ApplicationController\n\n  include WithinOrganization\n  before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }\n  before_action { params[:id] && @webhook = @server.webhooks.find_by_uuid!(params[:id]) }\n\n  def index\n    @webhooks = @server.webhooks.order(:url).to_a\n  end\n\n  def new\n    @webhook = @server.webhooks.build(all_events: true)\n  end\n\n  def create\n    @webhook = @server.webhooks.build(safe_params)\n    if @webhook.save\n      redirect_to_with_json [organization, @server, :webhooks]\n    else\n      render_form_errors \"new\", @webhook\n    end\n  end\n\n  def update\n    if @webhook.update(safe_params)\n      redirect_to_with_json [organization, @server, :webhooks]\n    else\n      render_form_errors \"edit\", @webhook\n    end\n  end\n\n  def destroy\n    @webhook.destroy\n    redirect_to_with_json [organization, @server, :webhooks]\n  end\n\n  def history\n    @current_page = params[:page] ? params[:page].to_i : 1\n    @requests = @server.message_db.webhooks.list(@current_page)\n  end\n\n  def history_request\n    @req = @server.message_db.webhooks.find(params[:uuid])\n  end\n\n  private\n\n  def safe_params\n    params.require(:webhook).permit(:name, :url, :all_events, :enabled, events: [])\n  end\n\nend\n"
  },
  {
    "path": "app/controllers/well_known_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass WellKnownController < ApplicationController\n\n  layout false\n\n  skip_before_action :set_browser_id\n  skip_before_action :login_required\n  skip_before_action :set_timezone\n\n  def jwks\n    render json: JWT::JWK::Set.new(Postal.signer.jwk).export.to_json\n  end\n\nend\n"
  },
  {
    "path": "app/helpers/application_helper.rb",
    "content": "# frozen_string_literal: true\n\nmodule ApplicationHelper\n\n  def format_delivery_details(server, text)\n    text = h(text)\n    text.gsub!(/<msg:(\\d+)>/) do\n      id = ::Regexp.last_match(1).to_i\n      link_to(\"message ##{id}\", organization_server_message_path(server.organization, server, id), class: \"u-link\")\n    end\n    text.html_safe\n  end\n\n  def style_width(width, options = {})\n    width = 100 if width > 100.0\n    width = 0 if width < 0.0\n    style = \"width:#{width}%;\"\n    if options[:color]\n      if width >= 100\n        style += \" background-color:#e2383a;\"\n      elsif width >= 90\n        style += \" background-color:#e8581f;\"\n      end\n    end\n    style\n  end\n\n  def domain_options_for_select(server, selected_domain = nil, options = {})\n    String.new.tap do |s|\n      s << \"<option></option>\"\n      server_domains = server.domains.verified.order(:name)\n      unless server_domains.empty?\n        s << \"<optgroup label='Server Domains'>\"\n        server_domains.each do |domain|\n          selected = domain == selected_domain ? \"selected='selected'\" : \"\"\n          s << \"<option value='#{domain.id}' #{selected}>#{domain.name}</option>\"\n        end\n        s << \"</optgroup>\"\n      end\n\n      organization_domains = server.organization.domains.verified.order(:name)\n      unless organization_domains.empty?\n        s << \"<optgroup label='Organization Domains'>\"\n        organization_domains.each do |domain|\n          selected = domain == selected_domain ? \"selected='selected'\" : \"\"\n          s << \"<option value='#{domain.id}' #{selected}>#{domain.name}</option>\"\n        end\n        s << \"</optgroup>\"\n      end\n    end.html_safe\n  end\n\n  def endpoint_options_for_select(server, selected_value = nil, options = {})\n    String.new.tap do |s|\n      s << \"<option></option>\"\n\n      http_endpoints = server.http_endpoints.order(:name).to_a\n      if http_endpoints.present?\n        s << \"<optgroup label='HTTP Endpoints'>\"\n        http_endpoints.each do |endpoint|\n          value = \"#{endpoint.class}##{endpoint.uuid}\"\n          selected = value == selected_value ? \"selected='selected'\" : \"\"\n          s << \"<option value='#{value}' #{selected}>#{endpoint.description}</option>\"\n        end\n        s << \"</optgroup>\"\n      end\n\n      smtp_endpoints = server.smtp_endpoints.order(:name).to_a\n      if smtp_endpoints.present?\n        s << \"<optgroup label='SMTP Endpoints'>\"\n        smtp_endpoints.each do |endpoint|\n          value = \"#{endpoint.class}##{endpoint.uuid}\"\n          selected = value == selected_value ? \"selected='selected'\" : \"\"\n          s << \"<option value='#{value}' #{selected}>#{endpoint.description}</option>\"\n        end\n        s << \"</optgroup>\"\n      end\n\n      address_endpoints = server.address_endpoints.order(:address).to_a\n      if address_endpoints.present?\n        s << \"<optgroup label='Address Endpoints'>\"\n        address_endpoints.each do |endpoint|\n          value = \"#{endpoint.class}##{endpoint.uuid}\"\n          selected = value == selected_value ? \"selected='selected'\" : \"\"\n          s << \"<option value='#{value}' #{selected}>#{endpoint.address}</option>\"\n        end\n        s << \"</optgroup>\"\n      end\n\n      unless options[:other] == false\n        s << \"<optgroup label='Other Options'>\"\n        Route::MODES.each do |mode|\n          next if mode == \"Endpoint\"\n\n          selected = (selected_value == mode ? \"selected='selected'\" : \"\")\n          text = t(\"route_modes.#{mode.underscore}\")\n          s << \"<option value='#{mode}' #{selected}>#{text}</option>\"\n        end\n        s << \"</optgroup>\"\n      end\n    end.html_safe\n  end\n\n  def postal_version_string\n    string = Postal.version\n    string += \" (#{Postal.branch})\" if Postal.branch &&\n                                       Postal.branch != \"main\"\n    string\n  end\n\nend\n"
  },
  {
    "path": "app/lib/dkim_header.rb",
    "content": "# frozen_string_literal: true\n\nclass DKIMHeader\n\n  def initialize(domain, message)\n    if domain && domain.dkim_status == \"OK\"\n      @domain_name = domain.name\n      @dkim_key = domain.dkim_key\n      @dkim_identifier = domain.dkim_identifier\n    else\n      @domain_name = Postal::Config.dns.return_path_domain\n      @dkim_key = Postal.signer.private_key\n      @dkim_identifier = Postal::Config.dns.dkim_identifier\n    end\n    @domain = domain\n    @message = message\n    @raw_headers, @raw_body = @message.gsub(/\\r?\\n/, \"\\r\\n\").split(/\\r\\n\\r\\n/, 2)\n  end\n\n  def dkim_header\n    \"DKIM-Signature: v=1; \" + dkim_properties.join(\"\\r\\n\\t\") + signature.scan(/.{1,72}/).join(\"\\r\\n\\t\")\n  end\n\n  private\n\n  def headers\n    @headers ||= @raw_headers.to_s.gsub(/\\r?\\n\\s/, \" \").split(/\\r?\\n/)\n  end\n\n  def header_names\n    normalized_headers.map { |h| h.split(\":\")[0].strip }\n  end\n\n  def normalized_headers\n    [].tap do |new_headers|\n      dkim_headers = headers.select do |h|\n        h.match(/\n          ^(\n            from|sender|reply-to|subject|date|message-id|to|cc|mime-version|content-type|content-transfer-encoding|\n            resent-to|resent-cc|resent-from|resent-sender|resent-message-id|in-reply-to|references|list-id|list-help|\n            list-owner|list-unsubscribe|list-unsubscribe-post|list-subscribe|list-post\n          ):/ix)\n      end\n      dkim_headers.each do |h|\n        new_headers << normalize_header(h)\n      end\n    end\n  end\n\n  def normalize_header(content)\n    content = content.dup\n\n    # From the DKIM RFC6376\n    # https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.2\n\n    # Split the key and value.\n    key, value = content.split(\":\", 2)\n\n    # Convert all header field names (not the header field values) to\n    # lowercase.  For example, convert \"SUBJect: AbC\" to \"subject: AbC\".\n    key.downcase!\n\n    # Unfold all header field continuation lines as described in [RFC5322]\n    value.gsub!(/\\r?\\n[ \\t]+/, \" \")\n\n    # Convert all sequences of one or more WSP characters to a single SP character.\n    value.gsub!(/[ \\t]+/, \" \")\n\n    # Delete all WSP characters at the end of each unfolded header field value.\n    value.gsub!(/[ \\t]*\\z/, \"\")\n\n    # Delete any WSP characters remaining after the colon separating the header field name from the header field value.\n    value.gsub!(/\\A[ \\t]*/, \"\")\n\n    # Join together\n    key + \":\" + value\n  end\n\n  def normalized_body\n    @normalized_body ||= begin\n      content = @raw_body.dup\n\n      # From the DKIM RFC6376\n      # https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.4\n\n      # a. Reduce whitespace\n      #\n      # * Reduce all sequences of WSP within a line to a single SP character.\n      content.gsub!(/[ \\t]+/, \" \")\n\n      # * Ignore all whitespace at the end of lines.  Implementations MUST NOT\n      #   remove the CRLF at the end of the line.\n      content.gsub!(/ \\r\\n/, \"\\r\\n\")\n\n      # b. Ignore all empty lines at the end of the message body.\n      content.gsub!(/[ \\r\\n]*\\z/, \"\")\n\n      content += \"\\r\\n\"\n      content\n    end\n  end\n\n  def body_hash\n    @body_hash ||= Base64.encode64(Digest::SHA256.digest(normalized_body)).strip\n  end\n\n  def dkim_properties\n    @dkim_properties ||= [].tap do |header|\n      header << \"a=rsa-sha256; c=relaxed/relaxed;\"\n      header << \"d=#{@domain_name};\"\n      header << \"s=#{@dkim_identifier}; t=#{Time.now.utc.to_i};\"\n      header << \"bh=#{body_hash};\"\n      header << \"h=#{header_names.join(':')};\"\n      header << \"b=\"\n    end\n  end\n\n  def dkim_header_for_signing\n    \"dkim-signature:v=1; #{dkim_properties.join(' ')}\"\n  end\n\n  def signable_header_string\n    (normalized_headers + [dkim_header_for_signing]).join(\"\\r\\n\")\n  end\n\n  def signature\n    Base64.encode64(@dkim_key.sign(OpenSSL::Digest.new(\"SHA256\"), signable_header_string)).gsub(\"\\n\", \"\")\n  end\n\nend\n"
  },
  {
    "path": "app/lib/dns_resolver.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"resolv\"\n\nclass DNSResolver\n\n  class LocalResolversUnavailableError < StandardError\n  end\n\n  attr_reader :nameservers\n  attr_reader :timeout\n\n  def initialize(nameservers)\n    @nameservers = nameservers\n  end\n\n  # Return all A records for the given name\n  #\n  # @param [String] name\n  # @return [Array<String>]\n  def a(name, **options)\n    get_resources(name, Resolv::DNS::Resource::IN::A, **options).map do |s|\n      s.address.to_s\n    end\n  end\n\n  # Return all AAAA records for the given name\n  #\n  # @param [String] name\n  # @return [Array<String>]\n  def aaaa(name, **options)\n    get_resources(name, Resolv::DNS::Resource::IN::AAAA, **options).map do |s|\n      s.address.to_s\n    end\n  end\n\n  # Return all TXT records for the given name\n  #\n  # @param [String] name\n  # @return [Array<String>]\n  def txt(name, **options)\n    get_resources(name, Resolv::DNS::Resource::IN::TXT, **options).map do |s|\n      s.data.to_s.strip\n    end\n  end\n\n  # Return all CNAME records for the given name\n  #\n  # @param [String] name\n  # @return [Array<String>]\n  def cname(name, **options)\n    get_resources(name, Resolv::DNS::Resource::IN::CNAME, **options).map do |s|\n      s.name.to_s.downcase\n    end\n  end\n\n  # Return all MX records for the given name\n  #\n  # @param [String] name\n  # @return [Array<Array<Integer, String>>]\n  def mx(name, **options)\n    records = get_resources(name, Resolv::DNS::Resource::IN::MX, **options).map do |m|\n      [m.preference.to_i, m.exchange.to_s]\n    end\n    records.sort do |a, b|\n      if a[0] == b[0]\n        [-1, 1].sample\n      else\n        a[0] <=> b[0]\n      end\n    end\n  end\n\n  # Return the effective nameserver names for a given domain name.\n  #\n  # @param [String] name\n  # @return [Array<String>]\n  def effective_ns(name, **options)\n    records = []\n    parts = name.split(\".\")\n    (parts.size - 1).times do |n|\n      d = parts[n, parts.size - n + 1].join(\".\")\n\n      records = get_resources(d, Resolv::DNS::Resource::IN::NS, **options).map do |s|\n        s.name.to_s\n      end\n\n      break if records.present?\n    end\n\n    records\n  end\n\n  # Return the hostname for a given IP address.\n  # Returns the IP address itself if no hostname can be determined.\n  #\n  # @param [String] ip_address\n  # @return [String]\n  def ip_to_hostname(ip_address, **options)\n    dns(**options) do |dns|\n      dns.getname(ip_address)&.to_s\n    end\n  rescue Resolv::ResolvError => e\n    raise if e.message =~ /timeout/ && options[:raise_timeout_errors]\n\n    ip_address\n  end\n\n  private\n\n  def dns(raise_timeout_errors: false)\n    Resolv::DNS.open(nameserver: @nameservers,\n                     raise_timeout_errors: raise_timeout_errors) do |dns|\n      dns.timeouts = [\n        Postal::Config.dns.timeout,\n        Postal::Config.dns.timeout / 2,\n        Postal::Config.dns.timeout / 2,\n      ]\n      yield dns\n    end\n  end\n\n  def get_resources(name, type, **options)\n    encoded_name = DomainName::Punycode.encode_hostname(name)\n    dns(**options) do |dns|\n      dns.getresources(encoded_name, type)\n    end\n  end\n\n  class << self\n\n    # Return a resolver which will use the nameservers for the given domain\n    #\n    # @param [String] name\n    # @return [DNSResolver]\n    def for_domain(name)\n      nameservers = local.effective_ns(name)\n      ips = nameservers.map do |ns|\n        local.a(ns)\n      end.flatten.uniq\n      new(ips)\n    end\n\n    # Return a local resolver to use for lookups\n    #\n    # @return [DNSResolver]\n    def local\n      @local ||= begin\n        resolv_conf_path = Postal::Config.dns.resolv_conf_path\n        raise LocalResolversUnavailableError, \"No resolver config found at #{resolv_conf_path}\" unless File.file?(resolv_conf_path)\n\n        resolv_conf = Resolv::DNS::Config.parse_resolv_conf(resolv_conf_path)\n        if resolv_conf.nil? || resolv_conf[:nameserver].nil? || resolv_conf[:nameserver].empty?\n          raise LocalResolversUnavailableError, \"Could not find nameservers in #{resolv_conf_path}\"\n        end\n\n        new(resolv_conf[:nameserver])\n      end\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/lib/message_dequeuer/base.rb",
    "content": "# frozen_string_literal: true\n\nmodule MessageDequeuer\n  class Base\n\n    class StopProcessing < StandardError\n    end\n\n    attr_reader :queued_message\n    attr_reader :logger\n    attr_reader :state\n\n    def initialize(queued_message, logger:, state: nil)\n      @queued_message = queued_message\n      @logger = logger\n      @state = state || State.new\n    end\n\n    def process\n      raise NotImplemented\n    end\n\n    class << self\n\n      def process(message, **kwargs)\n        new(message, **kwargs).process\n      end\n\n    end\n\n    private\n\n    def stop_processing\n      raise StopProcessing\n    end\n\n    def catch_stops\n      yield if block_given?\n      true\n    rescue StopProcessing\n      false\n    end\n\n    def remove_from_queue\n      @queued_message.destroy\n    end\n\n    def create_delivery(type, **kwargs)\n      @queued_message.message.create_delivery(type, **kwargs)\n    end\n\n    def log(text, **tags)\n      logger.info text, **tags\n    end\n\n    def increment_live_stats\n      queued_message.message.database.live_stats.increment(queued_message.message.scope)\n    end\n\n    def hold_if_server_development_mode\n      return if queued_message.manual?\n      return unless queued_message.server.mode == \"Development\"\n\n      log \"server is in development mode, holding\"\n      create_delivery \"Held\", details: \"Server is in development mode.\"\n      remove_from_queue\n      stop_processing\n    end\n\n    def log_sender_result\n      log_details = @result.details\n\n      if @additional_delivery_details\n        log_details += \".\" unless log_details =~ /\\.\\z/\n        log_details += \" \"\n        log_details += @additional_delivery_details\n      end\n\n      create_delivery @result.type, details: log_details,\n                                    output: @result.output&.strip,\n                                    sent_with_ssl: @result.secure,\n                                    log_id: @result.log_id,\n                                    time: @result.time\n    end\n\n    def handle_exception(exception)\n      log \"internal error: #{exception.class}: #{exception.message}\"\n      exception.backtrace.each { |line| log(line) }\n\n      queued_message.retry_later unless queued_message.destroyed?\n      log \"message requeued for trying later, at #{queued_message.retry_after}\"\n\n      if defined?(Sentry)\n        Sentry.capture_exception(exception, extra: {\n          server_id: queued_message.server_id,\n          queued_message_id: queued_message.message_id\n        })\n      end\n\n      queued_message.message&.create_delivery(\"Error\",\n                                              details: \"An internal error occurred while sending \" \\\n                                                       \"this message. This message will be retried \" \\\n                                                       \"automatically.\",\n                                              output: \"#{exception.class}: #{exception.message}\")\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/lib/message_dequeuer/incoming_message_processor.rb",
    "content": "# frozen_string_literal: true\n\nmodule MessageDequeuer\n  class IncomingMessageProcessor < Base\n\n    attr_reader :route\n\n    def process\n      log \"message is incoming\"\n\n      catch_stops do\n        handle_bounces\n        increment_live_stats\n        inspect_message\n        fail_if_spam\n        hold_if_server_development_mode\n        find_route\n        hold_or_reject_spam\n        accept_mail_without_endpoints\n        hold_messages\n        bounce_messages\n        send_message_to_sender\n        send_bounce_on_hard_fail\n        log_sender_result\n        finish_processing\n      end\n    rescue StandardError => e\n      handle_exception(e)\n    end\n\n    private\n\n    def handle_bounces\n      return unless queued_message.message.bounce\n\n      log \"message is a bounce\"\n      original_messages = queued_message.message.original_messages\n      unless original_messages.empty?\n        queued_message.message.original_messages.each do |orig_msg|\n          queued_message.message.update(bounce_for_id: orig_msg.id, domain_id: orig_msg.domain_id)\n          create_delivery \"Processed\", details: \"This has been detected as a bounce message for <msg:#{orig_msg.id}>.\"\n          orig_msg.bounce!(queued_message.message)\n          log \"bounce linked with message #{orig_msg.id}\"\n        end\n        remove_from_queue\n        stop_processing\n      end\n\n      # This message was sent to the return path but hasn't been matched\n      # to an original message. If we have a route for this, route it\n      # otherwise we'll drop at this point.\n      return unless queued_message.message.route_id.nil?\n\n      log \"no source messages found, hard failing\"\n      create_delivery \"HardFail\", details: \"This message was a bounce but we couldn't link it with any outgoing message and there was no route for it.\"\n      remove_from_queue\n      stop_processing\n    end\n\n    def inspect_message\n      return if queued_message.message.inspected\n\n      log \"inspecting message\"\n      queued_message.message.inspect_message\n      return unless queued_message.message.inspected\n\n      is_spam = queued_message.message.spam_score > queued_message.server.spam_threshold\n      if is_spam\n        queued_message.message.update(spam: true)\n        log \"message is spam (scored #{queued_message.message.spam_score}, threshold is #{queued_message.server.spam_threshold})\"\n      end\n\n      queued_message.message.append_headers(\n        \"X-Postal-Spam: #{queued_message.message.spam ? 'yes' : 'no'}\",\n        \"X-Postal-Spam-Threshold: #{queued_message.server.spam_threshold}\",\n        \"X-Postal-Spam-Score: #{queued_message.message.spam_score}\",\n        \"X-Postal-Threat: #{queued_message.message.threat ? 'yes' : 'no'}\"\n      )\n      log \"message inspected, headers added\", spam: queued_message.message.spam?, spam_score: queued_message.message.spam_score, threat: queued_message.message.threat?\n    end\n\n    def fail_if_spam\n      return if queued_message.message.spam_score < queued_message.server.spam_failure_threshold\n\n      log \"message has a spam score higher than the server's maxmimum, hard failing\", server_threshold: queued_message.server.spam_failure_threshold\n      create_delivery \"HardFail\",\n                      details: \"Message's spam score is higher than the failure threshold for this server. \" \\\n                               \"Threshold is currently #{queued_message.server.spam_failure_threshold}.\"\n      remove_from_queue\n      stop_processing\n    end\n\n    def find_route\n      @route = queued_message.message.route\n      return if @route\n\n      log \"no route and/or endpoint available for processing, hard failing\"\n      create_delivery \"HardFail\", details: \"Message does not have a route and/or endpoint available for delivery.\"\n      remove_from_queue\n      stop_processing\n    end\n\n    def hold_or_reject_spam\n      return unless queued_message.message.spam\n      return if queued_message.manual?\n\n      case @route.spam_mode\n      when \"Quarantine\"\n        log \"message is spam and route says to quarantine spam message, holding\"\n        create_delivery \"Held\", details: \"Message placed into quarantine.\"\n      when \"Fail\"\n        log \"message is spam and route says to fail spam message, hard failing\"\n        create_delivery \"HardFail\", details: \"Message is spam and the route specifies it should be failed.\"\n      else\n        return\n      end\n\n      remove_from_queue\n      stop_processing\n    end\n\n    def accept_mail_without_endpoints\n      return unless @route.mode == \"Accept\"\n\n      log \"route says to accept without endpoint, marking as processed\"\n      create_delivery \"Processed\", details: \"Message has been accepted but not sent to any endpoints.\"\n      remove_from_queue\n      stop_processing\n    end\n\n    def hold_messages\n      return unless @route.mode == \"Hold\"\n\n      if queued_message.manual?\n        log \"route says to hold and message was queued manually, marking as processed\"\n        create_delivery \"Processed\", details: \"Message has been processed.\"\n      else\n        log \"route says to hold, marking as held\"\n        create_delivery \"Held\", details: \"Message has been accepted but not sent to any endpoints.\"\n      end\n\n      remove_from_queue\n      stop_processing\n    end\n\n    def bounce_messages\n      return unless route.mode == \"Bounce\" || route.mode == \"Reject\"\n\n      log \"route says to bounce, hard failing and sending bounce\"\n\n      if id = queued_message.send_bounce\n        log \"bounce sent with id #{id}\"\n        create_delivery \"HardFail\", details: \"Message has been bounced because the route asks for this. See message <msg:#{id}>\"\n      end\n\n      remove_from_queue\n      stop_processing\n    end\n\n    def send_message_to_sender\n      @result = @state.send_result\n      return if @result\n\n      case queued_message.message.endpoint\n      when SMTPEndpoint\n        sender = @state.sender_for(SMTPSender, queued_message.message.recipient_domain, nil, servers: [queued_message.message.endpoint.to_smtp_client_server])\n      when HTTPEndpoint\n        sender = @state.sender_for(HTTPSender, queued_message.message.endpoint)\n      when AddressEndpoint\n        sender = @state.sender_for(SMTPSender, queued_message.message.endpoint.domain, nil, rcpt_to: queued_message.message.endpoint.address)\n      else\n        log \"invalid endpoint for route (#{queued_message.message.endpoint_type})\"\n        create_delivery \"HardFail\", details: \"Invalid endpoint for route.\"\n        remove_from_queue\n        stop_processing\n      end\n\n      @result = sender.send_message(queued_message.message)\n      return unless @result.connect_error\n\n      @state.send_result = @result\n    end\n\n    def send_bounce_on_hard_fail\n      return unless @result.type == \"HardFail\"\n\n      if @result.suppress_bounce\n        log \"suppressing bounce message after hard fail\"\n        return\n      end\n\n      return unless queued_message.message.send_bounces?\n\n      log \"sending a bounce because message hard failed\"\n      return unless bounce_id = queued_message.send_bounce\n\n      @additional_delivery_details = \"Sent bounce message to sender (see message <msg:#{bounce_id}>)\"\n    end\n\n    def finish_processing\n      if @result.retry\n        queued_message.retry_later(@result.retry.is_a?(Integer) ? @result.retry : nil)\n        log \"message requeued for trying later, at #{queued_message.retry_after}\"\n        queued_message.allocate_ip_address\n        queued_message.update_column(:ip_address_id, queued_message.ip_address&.id)\n        stop_processing\n      end\n\n      log \"message processing completed\"\n      queued_message.message.endpoint.mark_as_used\n      remove_from_queue\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/lib/message_dequeuer/initial_processor.rb",
    "content": "# frozen_string_literal: true\n\nmodule MessageDequeuer\n  class InitialProcessor < Base\n\n    include HasPrometheusMetrics\n\n    attr_accessor :send_result\n\n    def process\n      logger.tagged(original_queued_message: @queued_message.id) do\n        logger.info \"starting message unqueue\"\n        begin\n          catch_stops do\n            increment_dequeue_metric\n            check_message_exists\n            check_message_is_ready\n            find_other_messages_for_batch\n\n            # Process the original message and then all of those\n            # found for batching.\n            process_message(@queued_message)\n            @other_messages&.each { |message| process_message(message) }\n          end\n        ensure\n          @state.finished\n        end\n        logger.info \"finished message unqueue\"\n      end\n    end\n\n    private\n\n    def increment_dequeue_metric\n      time_in_queue = Time.now.to_f - @queued_message.created_at.to_f\n      log \"queue latency is #{time_in_queue}s\"\n      observe_prometheus_histogram :postal_message_queue_latency,\n                                   time_in_queue\n    end\n\n    def check_message_exists\n      return if @queued_message.message\n\n      log \"unqueue because backend message has been removed.\"\n      remove_from_queue\n      stop_processing\n    end\n\n    def check_message_is_ready\n      return if @queued_message.ready?\n\n      log \"skipping because message isn't ready for processing\"\n      @queued_message.unlock\n      stop_processing\n    end\n\n    def find_other_messages_for_batch\n      return unless Postal::Config.postal.batch_queued_messages?\n\n      @other_messages = @queued_message.batchable_messages(100)\n      log \"found #{@other_messages.size} associated messages to process at the same time\", batch_key: @queued_message.batch_key\n    rescue StandardError\n      @queued_message.unlock\n      raise\n    end\n\n    def process_message(queued_message)\n      logger.tagged(queued_message: queued_message.id) do\n        SingleMessageProcessor.process(queued_message, logger: @logger, state: @state)\n      end\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/lib/message_dequeuer/outgoing_message_processor.rb",
    "content": "# frozen_string_literal: true\n\nmodule MessageDequeuer\n  class OutgoingMessageProcessor < Base\n\n    def process\n      catch_stops do\n        check_domain\n        check_rcpt_to\n        add_tag\n        hold_if_credential_is_set_to_hold\n        hold_if_recipient_on_suppression_list\n        parse_content\n        inspect_message\n        fail_if_spam\n        add_outgoing_headers\n        check_send_limits\n        increment_live_stats\n        hold_if_server_development_mode\n        send_message_to_sender\n        add_recipient_to_suppression_list_on_too_many_hard_fails\n        remove_recipient_from_suppression_list_on_success\n        log_sender_result\n        finish_processing\n      end\n    rescue StandardError => e\n      handle_exception(e)\n    end\n\n    private\n\n    def check_domain\n      return if queued_message.message.domain\n\n      log \"message has no domain, hard failing\"\n      create_delivery \"HardFail\", details: \"Message's domain no longer exist\"\n      remove_from_queue\n      stop_processing\n    end\n\n    def check_rcpt_to\n      return unless queued_message.message.rcpt_to.blank?\n\n      log \"message has no 'to' address, hard failing\"\n      create_delivery \"HardFail\", details: \"Message doesn't have an RCPT to\"\n      remove_from_queue\n      stop_processing\n    end\n\n    def add_tag\n      return if queued_message.message.tag\n      return unless tag = queued_message.message.headers[\"x-postal-tag\"]\n\n      log \"added tag: #{tag.last}\"\n      queued_message.message.update(tag: tag.last)\n    end\n\n    def hold_if_credential_is_set_to_hold\n      return if queued_message.manual?\n      return if queued_message.message.credential.nil?\n      return unless queued_message.message.credential.hold?\n\n      log \"credential wants us to hold messages, holding\"\n      create_delivery \"Held\", details: \"Credential is configured to hold all messages authenticated by it.\"\n      remove_from_queue\n      stop_processing\n    end\n\n    def hold_if_recipient_on_suppression_list\n      return if queued_message.manual?\n      return unless sl = queued_message.server.message_db.suppression_list.get(:recipient, queued_message.message.rcpt_to)\n\n      log \"recipient is on the suppression list, holding\"\n      create_delivery \"Held\", details: \"Recipient (#{queued_message.message.rcpt_to}) is on the suppression list (reason: #{sl['reason']})\"\n      remove_from_queue\n      stop_processing\n    end\n\n    def parse_content\n      return unless queued_message.message.should_parse?\n\n      log \"parsing message content as it hasn't been parsed before\"\n      queued_message.message.parse_content\n    end\n\n    def inspect_message\n      return if queued_message.message.inspected\n      return unless queued_message.server.outbound_spam_threshold\n\n      log \"inspecting message\"\n      queued_message.message.inspect_message\n      return unless queued_message.message.inspected\n\n      if queued_message.message.spam_score >= queued_message.server.outbound_spam_threshold\n        queued_message.message.update(spam: true)\n      end\n\n      log \"message inspected successfully\", spam: queued_message.message.spam?, spam_score: queued_message.message.spam_score\n    end\n\n    def fail_if_spam\n      return unless queued_message.message.spam\n\n      log \"message is spam (#{queued_message.message.spam_score}), hard failing\", server_threshold: queued_message.server.outbound_spam_threshold\n      create_delivery \"HardFail\",\n                      details: \"Message is likely spam. Threshold is #{queued_message.server.outbound_spam_threshold} and \" \\\n                               \"the message scored #{queued_message.message.spam_score}.\"\n      remove_from_queue\n      stop_processing\n    end\n\n    def add_outgoing_headers\n      return if queued_message.message.has_outgoing_headers?\n\n      queued_message.message.add_outgoing_headers\n    end\n\n    def check_send_limits\n      if queued_message.server.send_limit_exceeded?\n        # If we're over the limit, we're going to be holding this message\n        log \"server send limit has been exceeded, holding\", send_limit: queued_message.server.send_limit\n        queued_message.server.update_columns(send_limit_exceeded_at: Time.now, send_limit_approaching_at: nil)\n        create_delivery \"Held\", details: \"Message held because send limit (#{queued_message.server.send_limit}) has been reached.\"\n        remove_from_queue\n        stop_processing\n      elsif queued_message.server.send_limit_approaching?\n        # If we're approaching the limit, just say we are but continue to process the message\n        queued_message.server.update_columns(send_limit_approaching_at: Time.now, send_limit_exceeded_at: nil)\n      else\n        queued_message.server.update_columns(send_limit_approaching_at: nil, send_limit_exceeded_at: nil)\n      end\n    end\n\n    def send_message_to_sender\n      @result = @state.send_result\n      return if @result\n\n      sender = @state.sender_for(SMTPSender,\n                                 queued_message.message.recipient_domain,\n                                 queued_message.ip_address)\n\n      @result = sender.send_message(queued_message.message)\n      return unless @result.connect_error\n\n      @state.send_result = @result\n    end\n\n    def add_recipient_to_suppression_list_on_too_many_hard_fails\n      return unless @result.type == \"HardFail\"\n\n      recent_hard_fails = queued_message.server.message_db.select(:messages,\n                                                                  where: {\n                                                                    rcpt_to: queued_message.message.rcpt_to,\n                                                                    status: \"HardFail\",\n                                                                    timestamp: { greater_than: 24.hours.ago.to_f }\n                                                                  },\n                                                                  count: true)\n      return if recent_hard_fails < 1\n\n      added = queued_message.server.message_db.suppression_list.add(:recipient, queued_message.message.rcpt_to,\n                                                                    reason: \"too many hard fails\")\n      return unless added\n\n      log \"Added #{queued_message.message.rcpt_to} to suppression list because #{recent_hard_fails} hard fails in 24 hours\"\n      @additional_delivery_details = \"Recipient added to suppression list (too many hard fails)\"\n    end\n\n    def remove_recipient_from_suppression_list_on_success\n      return unless @result.type == \"Sent\"\n\n      removed = queued_message.server.message_db.suppression_list.remove(:recipient, queued_message.message.rcpt_to)\n      return unless removed\n\n      log \"removed #{queued_message.message.rcpt_to} from suppression list\"\n      @additional_delivery_details = \"Recipient removed from suppression list\"\n    end\n\n    def finish_processing\n      if @result.retry\n        queued_message.retry_later(@result.retry.is_a?(Integer) ? @result.retry : nil)\n        log \"message requeued for trying later\", retry_after: queued_message.retry_after\n        stop_processing\n      end\n\n      log \"message processing complete\"\n      remove_from_queue\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/lib/message_dequeuer/single_message_processor.rb",
    "content": "# frozen_string_literal: true\n\nmodule MessageDequeuer\n  class SingleMessageProcessor < Base\n\n    def process\n      catch_stops do\n        check_message_exists\n        check_server_suspension\n        check_delivery_attempts\n        check_raw_message_exists\n\n        processor = nil\n        case queued_message.message.scope\n        when \"incoming\"\n          processor = IncomingMessageProcessor\n        when \"outgoing\"\n          processor = OutgoingMessageProcessor\n        else\n          create_delivery \"HardFail\", details: \"Scope #{queued_message.message.scope} is not valid\"\n          remove_from_queue\n          stop_processing\n        end\n\n        processor.process(queued_message, logger: @logger, state: @state)\n      end\n    rescue StandardError => e\n      handle_exception(e)\n    end\n\n    private\n\n    def check_message_exists\n      return if queued_message.message\n\n      log \"unqueueing because backend message has been removed\"\n      remove_from_queue\n      stop_processing\n    end\n\n    def check_server_suspension\n      return unless queued_message.server.suspended?\n\n      log \"server is suspended, holding message\"\n      create_delivery \"Held\", details: \"Mail server has been suspended. No e-mails can be processed at present. Contact support for assistance.\"\n      remove_from_queue\n      stop_processing\n    end\n\n    def check_delivery_attempts\n      return if queued_message.attempts < Postal::Config.postal.default_maximum_delivery_attempts\n\n      details = \"Maximum number of delivery attempts (#{queued_message.attempts}) has been reached.\"\n      if queued_message.message.scope == \"incoming\"\n        # Send bounces to incoming e-mails when they are hard failed\n        if bounce_id = queued_message.send_bounce\n          details += \" Bounce sent to sender (see message <msg:#{bounce_id}>)\"\n        end\n      elsif queued_message.message.scope == \"outgoing\"\n        # Add the recipient to the suppression list\n        if queued_message.server.message_db.suppression_list.add(:recipient, queued_message.message.rcpt_to, reason: \"too many soft fails\")\n          log \"added #{queued_message.message.rcpt_to} to suppression list because maximum attempts has been reached\"\n          details += \" Added #{queued_message.message.rcpt_to} to suppression list because delivery has failed #{queued_message.attempts} times.\"\n        end\n      end\n\n      log \"message has reached maximum number of attempts, hard failing\"\n      create_delivery \"HardFail\", details: details\n      remove_from_queue\n      stop_processing\n    end\n\n    def check_raw_message_exists\n      return if queued_message.message.raw_message?\n\n      log \"raw message has been removed, not sending\"\n      create_delivery \"HardFail\", details: \"Raw message has been removed. Cannot send message.\"\n      remove_from_queue\n      stop_processing\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/lib/message_dequeuer/state.rb",
    "content": "# frozen_string_literal: true\n\nmodule MessageDequeuer\n  class State\n\n    attr_accessor :send_result\n\n    def sender_for(klass, *args, **kwargs)\n      @cached_senders ||= {}\n      @cached_senders[[klass, args, kwargs]] ||= begin\n        klass_instance = klass.new(*args, **kwargs)\n        klass_instance.start\n        klass_instance\n      end\n    end\n\n    def finished\n      @cached_senders&.each_value do |sender|\n        sender.finish\n      rescue StandardError\n        false\n      end\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/lib/message_dequeuer.rb",
    "content": "# frozen_string_literal: true\n\nmodule MessageDequeuer\n\n  class << self\n\n    def process(message, logger:)\n      processor = InitialProcessor.new(message, logger: logger)\n      processor.process\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/lib/query_string.rb",
    "content": "# frozen_string_literal: true\n\nclass QueryString\n\n  def initialize(string)\n    @string = string.strip + \" \"\n  end\n\n  def [](value)\n    hash[value.to_s]\n  end\n\n  delegate :empty?, to: :hash\n\n  def hash\n    @hash ||= @string.scan(/([a-z]+):\\s*(?:(\\d{2,4}-\\d{2}-\\d{2}\\s\\d{2}:\\d{2})|\"(.*?)\"|(.*?))(\\s|\\z)/).each_with_object({}) do |(key, date, string_with_spaces, value), hash|\n      if date\n        actual_value = date\n      elsif string_with_spaces\n        actual_value = string_with_spaces\n      elsif value == \"[blank]\"\n        actual_value = nil\n      else\n        actual_value = value\n      end\n\n      if hash.keys.include?(key.to_s)\n        hash[key.to_s] = [hash[key.to_s]].flatten\n        hash[key.to_s] << actual_value\n      else\n        hash[key.to_s] = actual_value\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "app/lib/received_header.rb",
    "content": "# frozen_string_literal: true\n\nclass ReceivedHeader\n\n  OUR_HOSTNAMES = {\n    smtp: Postal::Config.postal.smtp_hostname,\n    http: Postal::Config.postal.web_hostname\n  }.freeze\n\n  class << self\n\n    def generate(server, helo, ip_address, method)\n      our_hostname = OUR_HOSTNAMES[method]\n      if our_hostname.nil?\n        raise Error, \"`method` is invalid (must be one of #{OUR_HOSTNAMES.join(', ')})\"\n      end\n\n      header = \"by #{our_hostname} with #{method.to_s.upcase}; #{Time.now.utc.rfc2822}\"\n\n      if server.nil? || server.privacy_mode == false\n        hostname = DNSResolver.local.ip_to_hostname(ip_address)\n        header = \"from #{helo} (#{hostname} [#{ip_address}]) #{header}\"\n      end\n\n      header\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/lib/reply_separator.rb",
    "content": "# frozen_string_literal: true\n\nclass ReplySeparator\n\n  RULES = [\n    /^-{2,10} $.*/m,\n    /^>*\\s*----- ?Original Message ?-----.*/m,\n    /^>*\\s*From:[^\\r\\n]*[\\r\\n]+Sent:.*/m,\n    /^>*\\s*From:[^\\r\\n]*[\\r\\n]+Date:.*/m,\n    /^>*\\s*-----Urspr.ngliche Nachricht----- .*/m,\n    /^>*\\s*Le[^\\r\\n]{10,200}a .crit ?:\\s*$.*/,\n    /^>*\\s*__________________.*/m,\n    /^>*\\s*On.{10,200}wrote:\\s*$.*/m,\n    /^>*\\s*Sent from my.*/m,\n    /^>*\\s*=== Please reply above this line ===.*/m,\n    /(^>.*\\n?){10,}/,\n  ].freeze\n\n  def self.separate(text)\n    return \"\" unless text.is_a?(String)\n\n    text = text.gsub(\"\\r\", \"\")\n    stripped = String.new\n    RULES.each do |rule|\n      text.gsub!(rule) do\n        stripped = ::Regexp.last_match(0).to_s + \"\\n\" + stripped\n        \"\"\n      end\n    end\n    stripped = stripped.strip\n    [text.strip, stripped.presence]\n  end\n\nend\n"
  },
  {
    "path": "app/lib/smtp_client/endpoint.rb",
    "content": "# frozen_string_literal: true\n\nmodule SMTPClient\n  class Endpoint\n\n    class SMTPSessionNotStartedError < StandardError\n    end\n\n    attr_reader :server\n    attr_reader :ip_address\n    attr_accessor :smtp_client\n\n    # @param server [Server] the server that this IP address is for\n    # @param ip_address [String] the IP address\n    def initialize(server, ip_address)\n      @server = server\n      @ip_address = ip_address\n    end\n\n    # Return a description of this server with its IP address\n    #\n    # @return [String]\n    def description\n      \"#{@ip_address}:#{@server.port} (#{@server.hostname})\"\n    end\n\n    # Return a string representation of this server\n    #\n    # @return [String]\n    def to_s\n      description\n    end\n\n    # Return true if this is an IPv6 address\n    #\n    # @return [Boolean]\n    def ipv6?\n      @ip_address.include?(\":\")\n    end\n\n    # Return true if this is an IPv4 address\n    #\n    # @return [Boolean]\n    def ipv4?\n      !ipv6?\n    end\n\n    # Start a new SMTP session and store the client with this server for future use as needed\n    #\n    # @param source_ip_address [IPAddress] the IP address to use as the source address for the connection\n    # @param allow_ssl [Boolean] whether to allow SSL for this connection, if false SSL mode is ignored\n    #\n    # @return [Net::SMTP]\n    def start_smtp_session(source_ip_address: nil, allow_ssl: true)\n      @smtp_client = Net::SMTP.new(@ip_address, @server.port)\n      @smtp_client.open_timeout = Postal::Config.smtp_client.open_timeout\n      @smtp_client.read_timeout = Postal::Config.smtp_client.read_timeout\n      @smtp_client.tls_hostname = @server.hostname\n\n      if source_ip_address\n        @source_ip_address = source_ip_address\n      end\n\n      if @source_ip_address\n        @smtp_client.source_address = ipv6? ? @source_ip_address.ipv6 : @source_ip_address.ipv4\n      end\n\n      if allow_ssl\n        case @server.ssl_mode\n        when SSLModes::AUTO\n          @smtp_client.enable_starttls_auto(self.class.ssl_context_without_verify)\n        when SSLModes::STARTTLS\n          @smtp_client.enable_starttls(self.class.ssl_context_with_verify)\n        when SSLModes::TLS\n          @smtp_client.enable_tls(self.class.ssl_context_with_verify)\n        else\n          @smtp_client.disable_starttls\n          @smtp_client.disable_tls\n        end\n      else\n        @smtp_client.disable_starttls\n        @smtp_client.disable_tls\n      end\n\n      @smtp_client.start(@source_ip_address ? @source_ip_address.hostname : self.class.default_helo_hostname)\n\n      @smtp_client\n    end\n\n    # Send a message to the current SMTP session (or create one if there isn't one for this endpoint).\n    # If sending messsage encouters some connection errors, retry again after re-establishing the SMTP\n    # session.\n    #\n    # @param raw_message [String] the raw message to send\n    # @param mail_from [String] the MAIL FROM address\n    # @param rcpt_to [String] the RCPT TO address\n    # @param retry_on_connection_error [Boolean] whether to retry the connection if there is a connection error\n    #\n    # @return [void]\n    def send_message(raw_message, mail_from, rcpt_to, retry_on_connection_error: true)\n      raise SMTPSessionNotStartedError if @smtp_client.nil? || (@smtp_client && !@smtp_client.started?)\n\n      @smtp_client.rset_errors\n      @smtp_client.send_message(raw_message, mail_from, [rcpt_to])\n    rescue Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError\n      if retry_on_connection_error\n        finish_smtp_session\n        start_smtp_session\n        return send_message(raw_message, mail_from, rcpt_to, retry_on_connection_error: false)\n      end\n\n      raise\n    end\n\n    # Reset the current SMTP session for this server if possible otherwise\n    # finish the session\n    #\n    # @return [void]\n    def reset_smtp_session\n      @smtp_client&.rset\n    rescue StandardError\n      finish_smtp_session\n    end\n\n    # Finish the current SMTP session for this server if possible.\n    #\n    # @return [void]\n    def finish_smtp_session\n      @smtp_client&.finish\n    rescue StandardError\n      nil\n    ensure\n      @smtp_client = nil\n    end\n\n    class << self\n\n      # Return the default HELO hostname to present to SMTP servers that\n      # we connect to\n      #\n      # @return [String]\n      def default_helo_hostname\n        Postal::Config.dns.helo_hostname ||\n          Postal::Config.postal.smtp_hostname ||\n          \"localhost\"\n      end\n\n      def ssl_context_with_verify\n        @ssl_context_with_verify ||= begin\n          c = OpenSSL::SSL::SSLContext.new\n          c.verify_mode = OpenSSL::SSL::VERIFY_PEER\n          c.cert_store = OpenSSL::X509::Store.new\n          c.cert_store.set_default_paths\n          c\n        end\n      end\n\n      def ssl_context_without_verify\n        @ssl_context_without_verify ||= begin\n          c = OpenSSL::SSL::SSLContext.new\n          c.verify_mode = OpenSSL::SSL::VERIFY_NONE\n          c\n        end\n      end\n\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/lib/smtp_client/server.rb",
    "content": "# frozen_string_literal: true\n\nmodule SMTPClient\n  class Server\n\n    attr_reader :hostname\n    attr_reader :port\n    attr_accessor :ssl_mode\n\n    def initialize(hostname, port: 25, ssl_mode: SSLModes::AUTO)\n      @hostname = hostname\n      @port = port\n      @ssl_mode = ssl_mode\n    end\n\n    # Return all IP addresses for this server by resolving its hostname.\n    # IPv6 addresses will be returned first.\n    #\n    # @return [Array<SMTPClient::Endpoint>]\n    def endpoints\n      ips = []\n\n      DNSResolver.local.aaaa(@hostname).each do |ip|\n        ips << Endpoint.new(self, ip)\n      end\n\n      DNSResolver.local.a(@hostname).each do |ip|\n        ips << Endpoint.new(self, ip)\n      end\n\n      ips\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/lib/smtp_client/ssl_modes.rb",
    "content": "# frozen_string_literal: true\n\nmodule SMTPClient\n  module SSLModes\n\n    AUTO = \"Auto\"\n    STARTTLS = \"STARTLS\"\n    TLS = \"TLS\"\n    NONE = \"None\"\n\n  end\nend\n"
  },
  {
    "path": "app/lib/smtp_server/client.rb",
    "content": "# frozen_string_literal: true\n\nmodule SMTPServer\n  class Client\n\n    extend HasPrometheusMetrics\n    include HasPrometheusMetrics\n\n    CRAM_MD5_DIGEST = OpenSSL::Digest.new(\"md5\")\n    LOG_REDACTION_STRING = \"[redacted]\"\n\n    attr_reader :logging_enabled\n    attr_reader :credential\n    attr_reader :ip_address\n    attr_reader :recipients\n    attr_reader :headers\n    attr_reader :state\n    attr_reader :helo_name\n\n    def initialize(ip_address)\n      @logging_enabled = true\n      @ip_address = ip_address\n\n      @cr_present = false\n      @previous_cr_present = nil\n\n      if @ip_address\n        check_ip_address\n        @state = :welcome\n      else\n        @state = :preauth\n      end\n      transaction_reset\n    end\n\n    def check_ip_address\n      return unless @ip_address &&\n                    Postal::Config.smtp_server.log_ip_address_exclusion_matcher &&\n                    @ip_address =~ Regexp.new(Postal::Config.smtp_server.log_ip_address_exclusion_matcher)\n\n      @logging_enabled = false\n    end\n\n    def transaction_reset\n      @recipients = []\n      @mail_from = nil\n      @data = nil\n      @headers = nil\n    end\n\n    def trace_id\n      @trace_id ||= SecureRandom.alphanumeric(8).upcase\n    end\n\n    def handle(data)\n      if data[-1] == \"\\r\"\n        @cr_present = true\n        data = data.chop # remove last character (\\r)\n      else\n        # This doesn't use `logger` because that will be nil when logging is disabled\n        # and we always want to log this.\n        Postal.logger&.warn(\"Detected line with invalid line ending (missing <CR>)\", trace_id: trace_id)\n        @cr_present = false\n      end\n\n      if @state == :preauth\n        return proxy(data)\n      end\n\n      logger&.debug \"\\e[32m<= #{sanitize_input_for_log(data.strip)}\\e[0m\"\n      if @proc\n        @proc.call(data)\n      else\n        handle_command(data)\n      end\n    ensure\n      @previous_cr_present = @cr_present\n    end\n\n    def finished?\n      @finished || false\n    end\n\n    def start_tls?\n      @start_tls || false\n    end\n\n    attr_writer :start_tls\n\n    def handle_command(data)\n      case data\n      when /^QUIT/i           then quit\n      when /^STARTTLS/i       then starttls\n      when /^EHLO/i           then ehlo(data)\n      when /^HELO/i           then helo(data)\n      when /^RSET/i           then rset\n      when /^NOOP/i           then noop\n      when /^AUTH PLAIN/i     then auth_plain(data)\n      when /^AUTH LOGIN/i     then auth_login(data)\n      when /^AUTH CRAM-MD5/i  then auth_cram_md5(data)\n      when /^MAIL FROM/i      then mail_from(data)\n      when /^RCPT TO/i        then rcpt_to(data)\n      when /^DATA/i           then data(data)\n      else\n        increment_error_count(\"invalid-command\")\n        \"502 Invalid/unsupported command\"\n      end\n    end\n\n    def logger\n      return nil unless @logging_enabled\n\n      @logger ||= Postal.logger.create_tagged_logger(trace_id: trace_id)\n    end\n\n    private\n\n    def proxy(data)\n      # inet-protocol, client-ip, proxy-ip, client-port, proxy-port\n      if m = data.match(/\\APROXY (.+) (.+) (.+) (.+) (.+)\\z/)\n        @ip_address = m[2]\n        check_ip_address\n        @state = :welcome\n        logger&.debug \"\\e[35mClient identified as #{@ip_address}\\e[0m\"\n        increment_command_count(\"PROXY\")\n        return \"220 #{Postal::Config.postal.smtp_hostname} ESMTP Postal/#{trace_id}\"\n      end\n\n      @finished = true\n      increment_error_count(\"proxy-error\")\n      \"502 Proxy Error\"\n    end\n\n    def quit\n      @finished = true\n      \"221 Closing Connection\"\n    end\n\n    def starttls\n      if Postal::Config.smtp_server.tls_enabled?\n        @start_tls = true\n        @tls = true\n        increment_command_count(\"STARTLS\")\n        \"220 Ready to start TLS\"\n      else\n        increment_error_count(\"tls-unavailable\")\n        \"502 TLS not available\"\n      end\n    end\n\n    def ehlo(data)\n      @helo_name = data.strip.split(\" \", 2)[1]\n      transaction_reset\n      @state = :welcomed\n      increment_command_count(\"EHLO\")\n      [\n        \"250-My capabilities are\",\n        Postal::Config.smtp_server.tls_enabled? && !@tls ? \"250-STARTTLS\" : nil,\n        \"250 AUTH CRAM-MD5 PLAIN LOGIN\",\n      ].compact\n    end\n\n    def helo(data)\n      @helo_name = data.strip.split(\" \", 2)[1]\n      transaction_reset\n      @state = :welcomed\n      increment_command_count(\"HELO\")\n      \"250 #{Postal::Config.postal.smtp_hostname}\"\n    end\n\n    def rset\n      transaction_reset\n      @state = :welcomed\n      increment_command_count(\"RSET\")\n      \"250 OK\"\n    end\n\n    def noop\n      \"250 OK\"\n    end\n\n    def auth_plain(data)\n      increment_command_count(\"AUTH PLAIN\")\n\n      handler = proc do |idata|\n        @proc = nil\n        idata = Base64.decode64(idata)\n        parts = idata.split(\"\\0\")\n        username = parts[-2]\n        password = parts[-1]\n        unless username && password\n          increment_error_count(\"missing-credentials\")\n          next \"535 Authenticated failed - protocol error\"\n        end\n\n        authenticate(password)\n      end\n\n      data = data.gsub(/AUTH PLAIN ?/i, \"\")\n      if data.strip == \"\"\n        @proc = handler\n        @password_expected_next = true\n        \"334\"\n      else\n        handler.call(data)\n      end\n    end\n\n    def auth_login(data)\n      increment_command_count(\"AUTH LOGIN\")\n\n      password_handler = proc do |idata|\n        @proc = nil\n        password = Base64.decode64(idata)\n        authenticate(password)\n      end\n\n      username_handler = proc do\n        @proc = password_handler\n        @password_expected_next = true\n        \"334 UGFzc3dvcmQ6\" # \"Password:\"\n      end\n\n      data = data.gsub(/AUTH LOGIN ?/i, \"\")\n      if data.strip == \"\"\n        @proc = username_handler\n        \"334 VXNlcm5hbWU6\" # \"Username:\"\n      else\n        username_handler.call(nil)\n      end\n    end\n\n    def authenticate(password)\n      if @credential = Credential.where(type: \"SMTP\", key: password).first\n        @credential.use\n        \"235 Granted for #{@credential.server.organization.permalink}/#{@credential.server.permalink}\"\n      else\n        logger&.warn \"Authentication failure for #{@ip_address}\"\n        increment_error_count(\"invalid-credentials\")\n        \"535 Invalid credential\"\n      end\n    end\n\n    def auth_cram_md5(data)\n      increment_command_count(\"AUTH CRAM-MD5\")\n\n      challenge = Digest::SHA1.hexdigest(Time.now.to_i.to_s + rand(100_000).to_s)\n      challenge = \"<#{challenge[0, 20]}@#{Postal::Config.postal.smtp_hostname}>\"\n\n      handler = proc do |idata|\n        @proc = nil\n        username, password = Base64.decode64(idata).split(\" \", 2).map { |a| a.chomp }\n        org_permlink, server_permalink = username.split(/[\\/_]/, 2)\n        server = ::Server.includes(:organization).where(organizations: { permalink: org_permlink }, permalink: server_permalink).first\n        if server.nil?\n          logger&.warn \"Authentication failure for #{@ip_address} (no server found matching #{username})\"\n          increment_error_count(\"invalid-credentials\")\n          next \"535 Denied\"\n        end\n\n        grant = nil\n        server.credentials.where(type: \"SMTP\").each do |credential|\n          correct_response = OpenSSL::HMAC.hexdigest(CRAM_MD5_DIGEST, credential.key, challenge)\n          next unless password == correct_response\n\n          @credential = credential\n          @credential.use\n          logger&.debug \"Authenticated with with credential #{credential.id}\"\n          grant = \"235 Granted for #{credential.server.organization.permalink}/#{credential.server.permalink}\"\n          break\n        end\n\n        if grant.nil?\n          logger&.warn \"Authentication failure for #{@ip_address} (invalid credential)\"\n          increment_error_count(\"invalid-credentials\")\n          next \"535 Denied\"\n        end\n\n        grant\n      end\n\n      @proc = handler\n      \"334 \" + Base64.encode64(challenge).gsub(/[\\r\\n]/, \"\")\n    end\n\n    def mail_from(data)\n      unless in_state(:welcomed, :mail_from_received)\n        increment_error_count(\"mail-from-out-of-order\")\n        return \"503 EHLO/HELO first please\"\n      end\n\n      @state = :mail_from_received\n      transaction_reset\n      if data =~ /AUTH=/\n        # Discard AUTH= parameter and anything that follows.\n        # We don't need this parameter as we don't trust any client to set it\n        mail_from_line = data.sub(/ *AUTH=.*/, \"\")\n      else\n        mail_from_line = data\n      end\n      @mail_from = mail_from_line.gsub(/MAIL FROM\\s*:\\s*/i, \"\").gsub(/.*</, \"\").gsub(/>.*/, \"\").strip\n      \"250 OK\"\n    end\n\n    def rcpt_to(data)\n      unless in_state(:mail_from_received, :rcpt_to_received)\n        increment_error_count(\"rcpt-to-out-of-order\")\n        return \"503 EHLO/HELO and MAIL FROM first please\"\n      end\n\n      rcpt_to = data.gsub(/RCPT TO\\s*:\\s*/i, \"\").gsub(/.*</, \"\").gsub(/>.*/, \"\").strip\n\n      if rcpt_to.blank?\n        increment_error_count(\"empty-rcpt-to\")\n        return \"501 RCPT TO should not be empty\"\n      end\n\n      uname, domain = rcpt_to.split(\"@\", 2)\n\n      if domain.blank?\n        increment_error_count(\"invalid-rcpt-to\")\n        return \"501 Invalid RCPT TO\"\n      end\n\n      uname, tag = uname.split(\"+\", 2)\n\n      if domain == Postal::Config.dns.return_path_domain || domain =~ /\\A#{Regexp.escape(Postal::Config.dns.custom_return_path_prefix)}\\./\n        # This is a return path\n        @state = :rcpt_to_received\n        if server = ::Server.where(token: uname).first\n          if server.suspended?\n            increment_error_count(\"server-suspended\")\n            \"535 Mail server has been suspended\"\n          else\n            logger&.debug \"Added bounce on server #{server.id}\"\n            @recipients << [:bounce, rcpt_to, server]\n            \"250 OK\"\n          end\n        else\n          increment_error_count(\"invalid-server-token\")\n          \"550 Invalid server token\"\n        end\n\n      elsif domain == Postal::Config.dns.route_domain\n        # This is an email direct to a route. This isn't actually supported yet.\n        @state = :rcpt_to_received\n        if route = Route.where(token: uname).first\n          if route.server.suspended?\n            increment_error_count(\"server-suspended\")\n            \"535 Mail server has been suspended\"\n          elsif route.mode == \"Reject\"\n            increment_error_count(\"route-rejected\")\n            \"550 Route does not accept incoming messages\"\n          else\n            logger&.debug \"Added route #{route.id} to recipients (tag: #{tag.inspect})\"\n            actual_rcpt_to = \"#{route.name}#{tag ? \"+#{tag}\" : ''}@#{route.domain.name}\"\n            @recipients << [:route, actual_rcpt_to, route.server, { route: route }]\n            \"250 OK\"\n          end\n        else\n          \"550 Invalid route token\"\n        end\n\n      elsif @credential\n        # This is outgoing mail for an authenticated user\n        @state = :rcpt_to_received\n        if @credential.server.suspended?\n          increment_error_count(\"server-suspended\")\n          \"535 Mail server has been suspended\"\n        else\n          logger&.debug \"Added external address '#{rcpt_to}'\"\n          @recipients << [:credential, rcpt_to, @credential.server]\n          \"250 OK\"\n        end\n\n      elsif uname && domain && route = Route.find_by_name_and_domain(uname, domain)\n        # This is incoming mail for a route\n        @state = :rcpt_to_received\n        if route.server.suspended?\n          increment_error_count(\"server-suspended\")\n          \"535 Mail server has been suspended\"\n        elsif route.mode == \"Reject\"\n          increment_error_count(\"route-rejection\")\n          \"550 Route does not accept incoming messages\"\n        else\n          logger&.debug \"Added route #{route.id} to recipients (tag: #{tag.inspect})\"\n          @recipients << [:route, rcpt_to, route.server, { route: route }]\n          \"250 OK\"\n        end\n\n      else\n        # User is trying to relay but is not authenticated. Try to authenticate by IP address\n        @credential = Credential.where(type: \"SMTP-IP\").all.sort_by { |c| c.ipaddr&.prefix || 0 }.reverse.find do |credential|\n          credential.ipaddr.include?(@ip_address) || (credential.ipaddr.ipv4? && credential.ipaddr.ipv4_mapped.include?(@ip_address))\n        end\n\n        if @credential\n          # Retry with credential\n          @credential.use\n          rcpt_to(data)\n        else\n          increment_error_count(\"authentication-required\")\n          logger&.warn \"Authentication failure for #{@ip_address}\"\n          \"530 Authentication required\"\n        end\n      end\n    end\n\n    def data(_data)\n      unless in_state(:rcpt_to_received)\n        increment_error_count(\"data-out-of-order\")\n        return \"503 HELO/EHLO, MAIL FROM and RCPT TO before sending data\"\n      end\n\n      @data = String.new.force_encoding(\"BINARY\")\n      @headers = {}\n      @receiving_headers = true\n\n      received_header = ReceivedHeader.generate(@credential&.server, @helo_name, @ip_address, :smtp)\n                                      .force_encoding(\"BINARY\")\n\n      @data << \"Received: #{received_header}\\r\\n\"\n      @headers[\"received\"] = [received_header]\n\n      handler = proc do |idata|\n        if idata == \".\" && @cr_present && @previous_cr_present\n          @logging_enabled = true\n          @proc = nil\n          finished\n        else\n          idata = idata.to_s.sub(/\\A\\.\\./, \".\")\n\n          if @credential&.server&.log_smtp_data?\n            # We want to log if enabled\n          else\n            logger&.debug \"Not logging further message data.\"\n            @logging_enabled = false\n          end\n\n          if @receiving_headers\n            if idata&.length&.zero?\n              @receiving_headers = false\n            elsif idata.to_s =~ /^\\s/\n              # This is a continuation of a header\n              if @header_key && @headers[@header_key.downcase] && @headers[@header_key.downcase].last\n                @headers[@header_key.downcase].last << idata.to_s\n              end\n            else\n              @header_key, value = idata.split(/:\\s*/, 2)\n              @headers[@header_key.downcase] ||= []\n              @headers[@header_key.downcase] << value\n            end\n          end\n          @data << idata\n          @data << \"\\r\\n\"\n          nil\n        end\n      end\n\n      @proc = handler\n      \"354 Go ahead\"\n    end\n\n    def finished\n      if @data.bytesize > Postal::Config.smtp_server.max_message_size.megabytes.to_i\n        transaction_reset\n        @state = :welcomed\n        increment_error_count(\"message-too-large\")\n        return format(\"552 Message too large (maximum size %dMB)\", Postal::Config.smtp_server.max_message_size)\n      end\n\n      if @headers[\"received\"].grep(/by #{Postal::Config.postal.smtp_hostname}/).count > 4\n        transaction_reset\n        @state = :welcomed\n        increment_error_count(\"loop-detected\")\n        return \"550 Loop detected\"\n      end\n\n      authenticated_domain = nil\n      if @credential\n        authenticated_domain = @credential.server.find_authenticated_domain_from_headers(@headers)\n        if authenticated_domain.nil?\n          transaction_reset\n          @state = :welcomed\n          increment_error_count(\"from-name-invalid\")\n          return \"530 From/Sender name is not valid\"\n        end\n      end\n\n      @recipients.each do |recipient|\n        type, rcpt_to, server, options = recipient\n\n        case type\n        when :credential\n          increment_message_count(\"outgoing\")\n\n          # Outgoing messages are just inserted\n          message = server.message_db.new_message\n          message.rcpt_to = rcpt_to\n          message.mail_from = @mail_from\n          message.raw_message = @data\n          message.received_with_ssl = @tls\n          message.scope = \"outgoing\"\n          message.domain_id = authenticated_domain&.id\n          message.credential_id = @credential.id\n          message.save\n\n        when :bounce\n          increment_message_count(\"bounce\")\n          if rp_route = server.routes.where(name: \"__returnpath__\").first\n            # If there's a return path route, we can use this to create the message\n            rp_route.create_messages do |msg|\n              msg.rcpt_to = rcpt_to\n              msg.mail_from = @mail_from\n              msg.raw_message = @data\n              msg.received_with_ssl = @tls\n              msg.bounce = 1\n            end\n          else\n            # There's no return path route, we just need to insert the mesage\n            # without going through the route.\n            message = server.message_db.new_message\n            message.rcpt_to = rcpt_to\n            message.mail_from = @mail_from\n            message.raw_message = @data\n            message.received_with_ssl = @tls\n            message.scope = \"incoming\"\n            message.bounce = 1\n            message.save\n          end\n        when :route\n          increment_message_count(\"incoming\")\n          options[:route].create_messages do |msg|\n            msg.rcpt_to = rcpt_to\n            msg.mail_from = @mail_from\n            msg.raw_message = @data\n            msg.received_with_ssl = @tls\n          end\n        end\n      end\n      transaction_reset\n      @state = :welcomed\n      \"250 OK\"\n    end\n\n    def in_state(*states)\n      states.include?(@state)\n    end\n\n    def sanitize_input_for_log(data)\n      if @password_expected_next\n        @password_expected_next = false\n        if data =~ /\\A[a-z0-9]{3,}=*\\z/i\n          return LOG_REDACTION_STRING\n        end\n      end\n\n      data = data.dup\n      data.gsub!(/(.*AUTH \\w+) (.*)\\z/i) { \"#{::Regexp.last_match(1)} #{LOG_REDACTION_STRING}\" }\n      data\n    end\n\n    def increment_error_count(error)\n      increment_prometheus_counter :postal_smtp_server_client_errors, labels: { error: error }\n    end\n\n    def increment_command_count(command)\n      increment_prometheus_counter :postal_smtp_server_commands_total, labels: { command: command }\n    end\n\n    def increment_message_count(type)\n      increment_prometheus_counter :postal_smtp_server_messages_total, labels: {\n        type: type,\n        tls: @tls ? \"yes\" : \"no\"\n      }\n    end\n\n    class << self\n\n      def register_prometheus_metrics\n        register_prometheus_counter :postal_smtp_server_commands_total,\n                                    docstring: \"The number of key commands received by the server\",\n                                    labels: [:command]\n\n        register_prometheus_counter :postal_smtp_server_client_errors,\n                                    docstring: \"The number of errors sent to a client\",\n                                    labels: [:error]\n\n        register_prometheus_counter :postal_smtp_server_messages_total,\n                                    docstring: \"The number of messages accepted by the SMTP server\",\n                                    labels: [:type, :tls]\n      end\n\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/lib/smtp_server/server.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"ipaddr\"\nrequire \"nio\"\n\nmodule SMTPServer\n  class Server\n\n    include HasPrometheusMetrics\n\n    class << self\n\n      def tls_private_key\n        @tls_private_key ||= OpenSSL::PKey.read(File.read(Postal::Config.smtp_server.tls_private_key_path))\n      end\n\n      def tls_certificates\n        @tls_certificates ||= begin\n          data = File.read(Postal::Config.smtp_server.tls_certificate_path)\n          certs = data.scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m)\n          certs.map do |c|\n            OpenSSL::X509::Certificate.new(c)\n          end.freeze\n        end\n      end\n\n    end\n\n    def initialize(options = {})\n      @options = options\n      @options[:debug] ||= false\n      register_prometheus_metrics\n      prepare_environment\n    end\n\n    def run\n      logger.tagged(component: \"smtp-server\") do\n        listen\n        run_event_loop\n      end\n    end\n\n    private\n\n    def prepare_environment\n      $\\ = \"\\r\\n\"\n      BasicSocket.do_not_reverse_lookup = true\n\n      trap(\"TERM\") do\n        $stdout.puts \"Received TERM signal, shutting down.\"\n        unlisten\n      end\n\n      trap(\"INT\") do\n        $stdout.puts \"Received INT signal, shutting down.\"\n        unlisten\n      end\n    end\n\n    def ssl_context\n      @ssl_context ||= begin\n        ssl_context      = OpenSSL::SSL::SSLContext.new\n        ssl_context.cert = self.class.tls_certificates[0]\n        ssl_context.extra_chain_cert = self.class.tls_certificates[1..]\n        ssl_context.key = self.class.tls_private_key\n        ssl_context.ssl_version = Postal::Config.smtp_server.ssl_version if Postal::Config.smtp_server.ssl_version\n        ssl_context.ciphers = Postal::Config.smtp_server.tls_ciphers if Postal::Config.smtp_server.tls_ciphers\n        ssl_context\n      end\n    end\n\n    def listen\n      bind_address = ENV.fetch(\"BIND_ADDRESS\", Postal::Config.smtp_server.default_bind_address)\n      port = ENV.fetch(\"PORT\", Postal::Config.smtp_server.default_port)\n\n      @server = TCPServer.open(bind_address, port)\n      @server.autoclose = false\n      @server.close_on_exec = false\n      if defined?(Socket::SOL_SOCKET) && defined?(Socket::SO_KEEPALIVE)\n        @server.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)\n      end\n      if defined?(Socket::SOL_TCP) && defined?(Socket::TCP_KEEPIDLE) && defined?(Socket::TCP_KEEPINTVL) && defined?(Socket::TCP_KEEPCNT)\n        @server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 50)\n        @server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10)\n        @server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, 5)\n      end\n\n      logger.info \"Listening on #{bind_address}:#{port}\"\n    end\n\n    def unlisten\n      # Instruct the nio loop to unlisten and wake it\n      @unlisten = true\n      @io_selector.wakeup\n    end\n\n    def run_event_loop\n      # Set up an instance of nio4r to monitor for connections and data\n      @io_selector = NIO::Selector.new\n      # Register the SMTP listener\n      @io_selector.register(@server, :r)\n      # Create a hash to contain a buffer for each client.\n      buffers = Hash.new { |h, k| h[k] = String.new.force_encoding(\"BINARY\") }\n      loop do\n        # Wait for an event to occur\n        @io_selector.select do |monitor|\n          # Get the IO from the nio monitor\n          io = monitor.io\n          # Is this event an incoming connection?\n          if io.is_a?(TCPServer)\n            begin\n              # Accept the connection\n              new_io = io.accept\n              increment_prometheus_counter :postal_smtp_server_connections_total\n              # Get the client's IP address and strip `::ffff:` for consistency.\n              client_ip_address = new_io.remote_address.ip_address.sub(/\\A::ffff:/, \"\")\n              if Postal::Config.smtp_server.proxy_protocol?\n                # If we are using the haproxy proxy protocol, we will be sent the\n                # client's IP later. Delay the welcome process.\n                client = Client.new(nil)\n                if Postal::Config.smtp_server.log_connections?\n                  client.logger&.debug \"Connection opened from #{client_ip_address}\"\n                end\n              else\n                # We're not using the proxy protocol so we already know the client's IP\n                client = Client.new(client_ip_address)\n                if Postal::Config.smtp_server.log_connections?\n                  client.logger&.debug \"Connection opened from #{client_ip_address}\"\n                end\n                # We know who the client is, welcome them.\n                client.logger&.debug \"Client identified as #{client_ip_address}\"\n                new_io.print(\"220 #{Postal::Config.postal.smtp_hostname} ESMTP Postal/#{client.trace_id}\")\n              end\n              # Register the client and its socket with nio4r\n              monitor = @io_selector.register(new_io, :r)\n              monitor.value = client\n            rescue StandardError => e\n              # If something goes wrong, log as appropriate and disconnect the client\n              if defined?(Sentry)\n                Sentry.capture_exception(e, extra: { trace_id: begin\n                  client.trace_id\n                rescue StandardError\n                  nil\n                end })\n              end\n              logger.error \"An error occurred while accepting a new client.\"\n              logger.error \"#{e.class}: #{e.message}\"\n              e.backtrace.each do |line|\n                logger.error line\n              end\n              increment_prometheus_counter :postal_smtp_server_exceptions_total,\n                                           labels: { error: e.class.to_s, type: \"client-accept\" }\n              begin\n                new_io.close\n              rescue StandardError\n                nil\n              end\n            end\n          else\n            # This event is not an incoming connection so it must be data from a client\n            begin\n              # Get the client from the nio monitor\n              client = monitor.value\n              # For now we assume the connection isn't closed\n              eof = false\n              # Is the client negotiating a TLS handshake?\n              if client.start_tls?\n                begin\n                  # Can we accept the TLS connection at this time?\n                  io.accept_nonblock\n                  # Increment prometheus\n                  increment_prometheus_counter :postal_smtp_server_tls_connections_total\n                  # We were able to accept the connection, the client is no longer handshaking\n                  client.start_tls = false\n                rescue IO::WaitReadable, IO::WaitWritable => e\n                  # Could not accept without blocking\n                  # We will try again later\n                  next\n                rescue OpenSSL::SSL::SSLError => e\n                  client.logger&.debug \"SSL Negotiation Failed: #{e.message}\"\n                  eof = true\n                end\n              else\n                # The client is not negotiating a TLS handshake at this time\n                begin\n                  # Read 10kiB of data at a time from the socket.\n                  buffers[io] << io.readpartial(10_240)\n\n                  # There is an extra step for SSL sockets\n                  if io.is_a?(OpenSSL::SSL::SSLSocket)\n                    buffers[io] << io.readpartial(10_240) while io.pending.positive?\n                  end\n                rescue EOFError, Errno::ECONNRESET, Errno::ETIMEDOUT\n                  # Client went away\n                  eof = true\n                end\n\n                # We line buffer, so look to see if we have received a newline\n                # and keep doing so until all buffered lines have been processed.\n                while buffers[io].index(\"\\n\")\n                  # Extract the line\n                  line, buffers[io] = buffers[io].split(\"\\n\", 2)\n                  # Send the received line to the client object for processing\n                  result = client.handle(line)\n                  # If the client object returned some data, write it back to the client\n                  next if result.nil?\n\n                  result = [result] unless result.is_a?(Array)\n                  result.compact.each do |iline|\n                    client.logger&.debug \"\\e[34m=> #{iline.strip}\\e[0m\"\n                    begin\n                      io.write(iline.to_s + \"\\r\\n\")\n                      io.flush\n                    rescue Errno::ECONNRESET\n                      # Client disconnected before we could write response\n                      eof = true\n                    end\n                  end\n                end\n\n                # Did the client request STARTTLS?\n                if !eof && client.start_tls?\n                  # Deregister the unencrypted IO\n                  @io_selector.deregister(io)\n                  buffers.delete(io)\n                  io = OpenSSL::SSL::SSLSocket.new(io, ssl_context)\n                  # Close the underlying IO when the TLS socket is closed\n                  io.sync_close = true\n                  # Register the new TLS socket with nio\n                  monitor = @io_selector.register(io, :r)\n                  monitor.value = client\n                end\n              end\n\n              # Has the client requested we close the connection?\n              if client.finished? || eof\n                client.logger&.debug \"Connection closed\"\n                # Deregister the socket and close it\n                @io_selector.deregister(io)\n                buffers.delete(io)\n                io.close\n                # If we have no more clients or listeners left, exit the process\n                if @io_selector.empty?\n                  Process.exit(0)\n                end\n              end\n            rescue StandardError => e\n              # Something went wrong, log as appropriate\n              client_id = client ? client.trace_id : \"------\"\n              if defined?(Sentry)\n                Sentry.capture_exception(e, extra: { trace_id: begin\n                  client&.trace_id\n                rescue StandardError\n                  nil\n                end })\n              end\n              logger.error \"An error occurred while processing data from a client.\", trace_id: client_id\n              logger.error \"#{e.class}: #{e.message}\", trace_id: client_id\n              e.backtrace.each do |iline|\n                logger.error iline, trace_id: client_id\n              end\n\n              increment_prometheus_counter :postal_smtp_server_exceptions_total,\n                                           labels: { error: e.class.to_s, type: \"data\" }\n\n              # Close all IO and forget this client\n              begin\n                @io_selector.deregister(io)\n              rescue StandardError\n                nil\n              end\n              buffers.delete(io)\n              begin\n                io.close\n              rescue StandardError\n                nil\n              end\n              if @io_selector.empty?\n                Process.exit(0)\n              end\n            end\n          end\n        end\n        # If unlisten has been called, stop listening\n        next unless @unlisten\n\n        @io_selector.deregister(@server)\n        @server.close\n        # If there's nothing left to do, shut down the process\n        if @io_selector.empty?\n          Process.exit(0)\n        end\n        # Clear the request\n        @unlisten = false\n      end\n    end\n\n    def logger\n      Postal.logger\n    end\n\n    def register_prometheus_metrics\n      register_prometheus_counter :postal_smtp_server_connections_total,\n                                  docstring: \"The number of connections made to the Postal SMTP server.\"\n\n      register_prometheus_counter :postal_smtp_server_exceptions_total,\n                                  docstring: \"The number of server exceptions encountered by the SMTP server\",\n                                  labels: [:type, :error]\n\n      register_prometheus_counter :postal_smtp_server_tls_connections_total,\n                                  docstring: \"The number of successfuly TLS connections established\"\n\n      Client.register_prometheus_metrics\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/lib/worker/jobs/base_job.rb",
    "content": "# frozen_string_literal: true\n\nmodule Worker\n  module Jobs\n    class BaseJob\n\n      def initialize(logger:)\n        @logger = logger\n      end\n\n      def call\n        # Override me.\n      end\n\n      def work_completed?\n        @work_completed == true\n      end\n\n      private\n\n      def work_completed!\n        @work_completed = true\n      end\n\n      attr_reader :logger\n\n    end\n  end\nend\n"
  },
  {
    "path": "app/lib/worker/jobs/process_queued_messages_job.rb",
    "content": "# frozen_string_literal: true\n\nmodule Worker\n  module Jobs\n    class ProcessQueuedMessagesJob < BaseJob\n\n      def call\n        @lock_time = Time.current\n        @locker = Postal.locker_name_with_suffix(SecureRandom.hex(8))\n\n        find_ip_addresses\n        lock_message_for_processing\n        obtain_locked_messages\n        process_messages\n        @messages_to_process\n      end\n\n      private\n\n      # Returns an array of IP address IDs that are present on the host that is\n      # running this job.\n      #\n      # @return [Array<Integer>]\n      def find_ip_addresses\n        ip_addresses = { 4 => [], 6 => [] }\n        Socket.ip_address_list.each do |address|\n          next if local_ip?(address.ip_address)\n\n          ip_addresses[address.ipv4? ? 4 : 6] << address.ip_address\n        end\n        @ip_addresses = IPAddress.where(ipv4: ip_addresses[4]).or(IPAddress.where(ipv6: ip_addresses[6])).pluck(:id)\n      end\n\n      # Is the given IP address a local address?\n      #\n      # @param [String] ip\n      # @return [Boolean]\n      def local_ip?(ip)\n        !!(ip =~ /\\A(127\\.|fe80:|::)/)\n      end\n\n      # Obtain a queued message from the database for processing\n      #\n      # @return [void]\n      def lock_message_for_processing\n        QueuedMessage.where(ip_address_id: [nil, @ip_addresses])\n                     .where(locked_by: nil, locked_at: nil)\n                     .ready_with_delayed_retry\n                     .limit(1)\n                     .update_all(locked_by: @locker, locked_at: @lock_time)\n      end\n\n      # Get a full list of all messages which we can process (i.e. those which have just\n      # been locked by us for processing)\n      #\n      # @return [void]\n      def obtain_locked_messages\n        @messages_to_process = QueuedMessage.where(locked_by: @locker, locked_at: @lock_time)\n      end\n\n      # Process the messages we obtained from the database\n      #\n      # @return [void]\n      def process_messages\n        @messages_to_process.each do |message|\n          work_completed!\n          MessageDequeuer.process(message, logger: logger)\n        end\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "app/lib/worker/jobs/process_webhook_requests_job.rb",
    "content": "# frozen_string_literal: true\n\nmodule Worker\n  module Jobs\n    class ProcessWebhookRequestsJob < BaseJob\n\n      def call\n        @lock_time = Time.current\n        @locker = Postal.locker_name_with_suffix(SecureRandom.hex(8))\n\n        lock_request_for_processing\n        obtain_locked_requests\n        process_requests\n      end\n\n      private\n\n      # Obtain a webhook request from the database for processing\n      #\n      # @return [void]\n      def lock_request_for_processing\n        WebhookRequest.unlocked\n                      .ready\n                      .limit(1)\n                      .update_all(locked_by: @locker, locked_at: @lock_time)\n      end\n\n      # Get a full list of all webhooks which we can process (i.e. those which have just\n      # been locked by us for processing)\n      #\n      # @return [void]\n      def obtain_locked_requests\n        @requests_to_process = WebhookRequest.where(locked_by: @locker, locked_at: @lock_time)\n      end\n\n      # Process the webhook requests we obtained from the database\n      #\n      # @return [void]\n      def process_requests\n        @requests_to_process.each do |request|\n          work_completed!\n\n          WebhookDeliveryService.new(webhook_request: request).call\n        end\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "app/lib/worker/process.rb",
    "content": "# frozen_string_literal: true\n\nmodule Worker\n  # The Postal Worker process is responsible for handling all background tasks. This includes processing of all\n  # messages, webhooks and other administrative tasks. There are two main types of background work which is completed,\n  # jobs and scheduled tasks.\n  #\n  # The 'Jobs' here allow for the continuous monitoring of a database table (or queue) and processing of any new items\n  # which may appear in that. The polling takes place every 5 seconds by default and the work is able to run multiple\n  # threads to look for and process this work.\n  #\n  # Scheduled Tasks allow for code to be executed on a ROUGH schedule. This is used for administrative tasks.  A single\n  # thread will run within each worker process and attempt to acquire the 'tasks' role. If successful it will run all\n  # tasks which are due to be run. The tasks are then scheduled to run again at a future time. Workers which are not\n  # successful in acquiring the role will not run any tasks but will still attempt to acquire a lock in case the current\n  # acquiree disappears.\n  #\n  # The worker process will run until it receives a TERM or INT signal. It will then attempt to gracefully shut down\n  # after it has completed any outstanding jobs which are already inflight.\n  class Process\n\n    include HasPrometheusMetrics\n\n    # An array of job classes that should be processed each time the worker ticks.\n    #\n    # @return [Array<Class>]\n    JOBS = [\n      Jobs::ProcessQueuedMessagesJob,\n      Jobs::ProcessWebhookRequestsJob,\n    ].freeze\n\n    # An array of tasks that should be processed\n    #\n    # @return [Array<Class>]\n    TASKS = [\n      ActionDeletionsScheduledTask,\n      CheckAllDNSScheduledTask,\n      CleanupAuthieSessionsScheduledTask,\n      ExpireHeldMessagesScheduledTask,\n      ProcessMessageRetentionScheduledTask,\n      PruneSuppressionListsScheduledTask,\n      PruneWebhookRequestsScheduledTask,\n      SendNotificationsScheduledTask,\n      TidyQueuedMessagesTask,\n    ].freeze\n\n    # @param [Integer] thread_count The number of worker threads to run in this process\n    def initialize(thread_count: Postal::Config.worker.threads,\n                   work_sleep_time: 5,\n                   task_sleep_time: 60)\n      @thread_count = thread_count\n      @exit_pipe_read, @exit_pipe_write = IO.pipe\n      @work_sleep_time = work_sleep_time\n      @task_sleep_time = task_sleep_time\n      @threads = []\n\n      setup_prometheus\n    end\n\n    def run\n      logger.tagged(component: \"worker\") do\n        setup_traps\n        ensure_connection_pool_size_is_suitable\n        start_work_threads\n        start_tasks_thread\n        wait_for_threads\n      end\n    end\n\n    private\n\n    # Install signal traps to allow for graceful shutdown\n    #\n    # @return [void]\n    def setup_traps\n      trap(\"INT\") { receive_signal(\"INT\") }\n      trap(\"TERM\") { receive_signal(\"TERM\") }\n    end\n\n    # Receive a signal and set the shutdown flag\n    #\n    # @param [String] signal The signal that was received z\n    # @return [void]\n    def receive_signal(signal)\n      puts \"Received #{signal} signal. Stopping when able.\"\n      @shutdown = true\n      @exit_pipe_write.close\n    end\n\n    # Wait for the period of time and return true or false if shutdown has been requested. If the shutdown is\n    # requested during the wait, it will return immediately otherwise it will return false when it has finished\n    # waiting for the period of time.\n    #\n    # @param [Integer] wait_time The time to wait for\n    # @return [Boolean]\n    def shutdown_after_wait?(wait_time)\n      @exit_pipe_read.wait_readable(wait_time) ? true : false\n    end\n\n    # Ensure that the connection pool is big enough for the number of threads\n    # configured.\n    #\n    # @return [void]\n    def ensure_connection_pool_size_is_suitable\n      current_pool_size = ActiveRecord::Base.connection_pool.size\n      desired_pool_size = @thread_count + 3\n\n      return if current_pool_size >= desired_pool_size\n\n      logger.warn \"number of worker threads (#{@thread_count}) is more  \" \\\n                  \"than the db connection pool size (#{current_pool_size}+3), \" \\\n                  \"increasing connection pool size to #{desired_pool_size}\"\n\n      Postal.change_database_connection_pool_size(desired_pool_size)\n    end\n\n    # Wait for all threads to complete\n    #\n    # @return [void]\n    def wait_for_threads\n      @threads.each(&:join)\n    end\n\n    # Start the worker threads\n    #\n    # @return [void]\n    def start_work_threads\n      logger.info \"starting #{@thread_count} work threads\"\n      @thread_count.times do |index|\n        start_work_thread(index)\n      end\n    end\n\n    # Start a worker thread\n    #\n    # @return [void]\n    def start_work_thread(index)\n      @threads << Thread.new do\n        logger.tagged(component: \"worker\", thread: \"work#{index}\") do\n          logger.info \"started work thread #{index}\"\n          loop do\n            work_completed = work(index)\n\n            if shutdown_after_wait?(work_completed ? 0 : @work_sleep_time)\n              break\n            end\n          end\n\n          logger.info \"stopping work thread #{index}\"\n        end\n      end\n    end\n\n    # Actually perform the work for this tick. This will call each job which has been registered.\n    #\n    # @return [Boolean] Whether any work was completed in this job or not\n    def work(thread)\n      completed_work = 0\n      ActiveRecord::Base.connection_pool.with_connection do\n        JOBS.each do |job_class|\n          capture_errors do\n            job = job_class.new(logger: logger)\n\n            time = Benchmark.realtime { job.call }\n\n            observe_prometheus_histogram :postal_worker_job_runtime,\n                                         time,\n                                         labels: {\n                                          thread: thread,\n                                          job: job_class.to_s.split(\"::\").last\n                                         }\n\n            if job.work_completed?\n              completed_work += 1\n              increment_prometheus_counter :postal_worker_job_executions,\n                                           labels: {\n                                              thread: thread,\n                                              job: job_class.to_s.split(\"::\").last\n                                           }\n            end\n          end\n        end\n      end\n      completed_work.positive?\n    end\n\n    # Start the tasks thread\n    #\n    # @return [void]\n    def start_tasks_thread\n      logger.info \"starting tasks thread\"\n      @threads << Thread.new do\n        logger.tagged(component: \"worker\", thread: \"tasks\") do\n          loop do\n            run_tasks\n\n            if shutdown_after_wait?(@task_sleep_time)\n              break\n            end\n          end\n\n          logger.info \"stopping tasks thread\"\n          ActiveRecord::Base.connection_pool.with_connection do\n            if WorkerRole.release(:tasks)\n              logger.info \"released tasks role\"\n            end\n          end\n        end\n      end\n    end\n\n    # Run the tasks. This will attempt to acquire the tasks role and if successful it will all the registered\n    # tasks if they are due to be run.\n    #\n    # @return [void]\n    def run_tasks\n      role_acquisition_status = ActiveRecord::Base.connection_pool.with_connection do\n        WorkerRole.acquire(:tasks)\n      end\n\n      case role_acquisition_status\n      when :stolen\n        logger.info \"acquired task role by stealing it from a lazy worker\"\n      when :created\n        logger.info \"acquired task role by creating it\"\n      when :renewed\n        logger.debug \"acquired task role by renewing it\"\n      else\n        logger.debug \"could not acquire task role, not doing anything\"\n        return false\n      end\n\n      ActiveRecord::Base.connection_pool.with_connection do\n        TASKS.each { |task| run_task(task) }\n      end\n    end\n\n    # Run a single task\n    #\n    # @param [Class] task The task to run\n    # @return [void]\n    def run_task(task)\n      logger.tagged task: task do\n        scheduled_task = ScheduledTask.find_by(name: task.to_s)\n        if scheduled_task.nil?\n          logger.info \"no existing task object, creating it now\"\n          scheduled_task = ScheduledTask.create!(name: task.to_s, next_run_after: task.next_run_after)\n        end\n\n        next unless scheduled_task.next_run_after < Time.current\n\n        logger.info \"running task\"\n\n        time = 0\n        capture_errors do\n          time = Benchmark.realtime do\n            task.new(logger: logger).call\n          end\n\n          observe_prometheus_histogram :postal_worker_task_runtime,\n                                       time,\n                                       labels: {\n                                        task: task.to_s.split(\"::\").last\n                                       }\n        end\n\n        next_run_after = task.next_run_after\n        logger.info \"scheduling task to next run at #{next_run_after}\"\n        scheduled_task.update!(next_run_after: next_run_after)\n      end\n    end\n\n    # Return the logger\n    #\n    # @return [Klogger::Logger]\n    def logger\n      Postal.logger\n    end\n\n    # Capture exceptions and handle this as appropriate.\n    #\n    # @yield The block of code to run\n    # @return [void]\n    def capture_errors\n      yield\n    rescue StandardError => e\n      logger.error \"#{e.class} (#{e.message})\"\n      e.backtrace.each { |line| logger.error line }\n      Sentry.capture_exception(e) if defined?(Sentry)\n\n      increment_prometheus_counter :postal_worker_errors,\n                                   labels: { error: e.class.to_s }\n    end\n\n    def setup_prometheus\n      register_prometheus_counter :postal_worker_job_executions,\n                                  docstring: \"The number of jobs worked by a worker where work was completed\",\n                                  labels: [:thread, :job]\n\n      register_prometheus_histogram :postal_worker_job_runtime,\n                                    docstring: \"The time taken to process jobs (in seconds)\",\n                                    labels: [:thread, :job]\n\n      register_prometheus_counter :postal_worker_errors,\n                                  docstring: \"The number of errors encountered while processing jobs\",\n                                  labels: [:error]\n\n      register_prometheus_histogram :postal_worker_task_runtime,\n                                    docstring: \"The time taken to process tasks (in seconds)\",\n                                    labels: [:task]\n\n      register_prometheus_histogram :postal_message_queue_latency,\n                                    docstring: \"The length of time between a message being queued and being dequeued (in seconds)\"\n    end\n\n  end\nend\n"
  },
  {
    "path": "app/mailers/app_mailer.rb",
    "content": "# frozen_string_literal: true\n\nclass AppMailer < ApplicationMailer\n\n  def verify_domain(domain, email_address, user)\n    @domain = domain\n    @email_address = email_address\n    @user = user\n    mail to: email_address, subject: \"Verify your ownership of #{@domain.name}\"\n  end\n\n  def password_reset(user, return_to = nil)\n    @user = user\n    @return_to = return_to\n    mail to: @user.email_address, subject: \"Reset your Postal password\"\n  end\n\n  def server_send_limit_approaching(server)\n    @server = server\n    mail to: @server.organization.notification_addresses, subject: \"[#{server.full_permalink}] Mail server is approaching its send limit\"\n  end\n\n  def server_send_limit_exceeded(server)\n    @server = server\n    mail to: @server.organization.notification_addresses, subject: \"[#{server.full_permalink}] Mail server has exceeded its send limit\"\n  end\n\n  def server_suspended(server)\n    @server = server\n    mail to: @server.organization.notification_addresses, subject: \"[#{server.full_permalink}] Your mail server has been suspended\"\n  end\n\n  def test_message(recipient)\n    mail to: recipient, subject: \"Postal SMTP Test Message\"\n  end\n\nend\n"
  },
  {
    "path": "app/mailers/application_mailer.rb",
    "content": "# frozen_string_literal: true\n\nclass ApplicationMailer < ActionMailer::Base\n\n  default from: \"#{Postal::Config.smtp.from_name} <#{Postal::Config.smtp.from_address}>\"\n  layout false\n\nend\n"
  },
  {
    "path": "app/models/additional_route_endpoint.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: additional_route_endpoints\n#\n#  id            :integer          not null, primary key\n#  route_id      :integer\n#  endpoint_type :string(255)\n#  endpoint_id   :integer\n#  created_at    :datetime         not null\n#  updated_at    :datetime         not null\n#\n\nclass AdditionalRouteEndpoint < ApplicationRecord\n\n  belongs_to :route\n  belongs_to :endpoint, polymorphic: true\n\n  validate :validate_endpoint_belongs_to_server\n  validate :validate_wildcard\n  validate :validate_uniqueness\n\n  def self.find_by_endpoint(endpoint)\n    class_name, id = endpoint.split(\"#\", 2)\n    unless Route::ENDPOINT_TYPES.include?(class_name)\n      raise Postal::Error, \"Invalid endpoint class name '#{class_name}'\"\n    end\n\n    return unless uuid = class_name.constantize.find_by_uuid(id)\n\n    where(endpoint_type: class_name, endpoint_id: uuid).first\n  end\n\n  def _endpoint\n    \"#{endpoint_type}##{endpoint.uuid}\"\n  end\n\n  def _endpoint=(value)\n    if value && value =~ /\\#/\n      class_name, id = value.split(\"#\", 2)\n      unless Route::ENDPOINT_TYPES.include?(class_name)\n        raise Postal::Error, \"Invalid endpoint class name '#{class_name}'\"\n      end\n\n      self.endpoint = class_name.constantize.find_by_uuid(id)\n    else\n      self.endpoint = nil\n    end\n  end\n\n  private\n\n  def validate_endpoint_belongs_to_server\n    return unless endpoint && endpoint&.server != route.server\n\n    errors.add :endpoint, :invalid\n  end\n\n  def validate_uniqueness\n    return unless endpoint == route.endpoint\n\n    errors.add :base, \"You can only add an endpoint to a route once\"\n  end\n\n  def validate_wildcard\n    return unless route.wildcard?\n    return unless endpoint_type == \"SMTPEndpoint\" || endpoint_type == \"AddressEndpoint\"\n\n    errors.add :base, \"SMTP or address endpoints are not permitted on wildcard routes\"\n  end\n\nend\n"
  },
  {
    "path": "app/models/address_endpoint.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: address_endpoints\n#\n#  id           :integer          not null, primary key\n#  server_id    :integer\n#  uuid         :string(255)\n#  address      :string(255)\n#  last_used_at :datetime\n#  created_at   :datetime         not null\n#  updated_at   :datetime         not null\n#\n\nclass AddressEndpoint < ApplicationRecord\n\n  include HasUUID\n\n  belongs_to :server\n  has_many :routes, as: :endpoint\n  has_many :additional_route_endpoints, dependent: :destroy, as: :endpoint\n\n  validates :address, presence: true, format: { with: /@/ }, uniqueness: { scope: [:server_id], message: \"has already been added\", case_sensitive: false }\n\n  before_destroy :update_routes\n\n  def mark_as_used\n    update_column(:last_used_at, Time.now)\n  end\n\n  def update_routes\n    routes.each { |r| r.update(endpoint: nil, mode: \"Reject\") }\n  end\n\n  def description\n    address\n  end\n\n  def domain\n    address.split(\"@\", 2).last\n  end\n\nend\n"
  },
  {
    "path": "app/models/application_record.rb",
    "content": "# frozen_string_literal: true\n\nclass ApplicationRecord < ActiveRecord::Base\n\n  self.abstract_class = true\n  self.inheritance_column = \"sti_type\"\n  nilify_blanks\n\nend\n"
  },
  {
    "path": "app/models/bounce_message.rb",
    "content": "# frozen_string_literal: true\n\nclass BounceMessage\n\n  def initialize(server, message)\n    @server = server\n    @message = message\n  end\n\n  def raw_message\n    mail = Mail.new\n    mail.to = @message.mail_from\n    mail.from = \"Mail Delivery Service <#{@message.route.description}>\"\n    mail.subject = \"Mail Delivery Failed (#{@message.subject})\"\n    mail.text_part = body\n    mail.attachments[\"Original Message.eml\"] = { mime_type: \"message/rfc822\", encoding: \"quoted-printable\", content: @message.raw_message }\n    mail.message_id = \"<#{SecureRandom.uuid}@#{Postal::Config.dns.return_path_domain}>\"\n    mail.to_s\n  end\n\n  def queue\n    message = @server.message_db.new_message\n    message.scope = \"outgoing\"\n    message.rcpt_to = @message.mail_from\n    message.mail_from = @message.route.description\n    message.domain_id = @message.domain&.id\n    message.raw_message = raw_message\n    message.bounce = true\n    message.bounce_for_id = @message.id\n    message.save\n    message.id\n  end\n\n  def postmaster_address\n    @server.postmaster_address || \"postmaster@#{@message.domain&.name || Postal::Config.postal.web_hostname}\"\n  end\n\n  private\n\n  def body\n    <<~BODY\n      This is the mail delivery service responsible for delivering mail to #{@message.route.description}.\n\n      The message you've sent cannot be delivered. Your original message is attached to this message.\n\n      For further assistance please contact #{postmaster_address}. Please include the details below to help us identify the issue.\n\n      Message Token: #{@message.token}@#{@server.token}\n      Orginal Message ID: #{@message.message_id}\n      Mail from: #{@message.mail_from}\n      Rcpt To: #{@message.rcpt_to}\n    BODY\n  end\n\nend\n"
  },
  {
    "path": "app/models/concerns/.keep",
    "content": ""
  },
  {
    "path": "app/models/concerns/has_authentication.rb",
    "content": "# frozen_string_literal: true\n\nmodule HasAuthentication\n\n  extend ActiveSupport::Concern\n\n  included do\n    has_secure_password validations: false\n\n    validates :password, length: { minimum: 8, allow_blank: true }\n    validates :password, confirmation: { allow_blank: true }\n    validate :validate_password_presence\n\n    before_save :clear_password_reset_token_on_password_change\n\n    scope :with_password, -> { where.not(password_digest: nil) }\n  end\n\n  class_methods do\n    def authenticate(email_address, password)\n      user = find_by(email_address: email_address)\n      raise Postal::Errors::AuthenticationError, \"InvalidEmailAddress\" if user.nil?\n      raise Postal::Errors::AuthenticationError, \"InvalidPassword\" unless user.authenticate(password)\n\n      user\n    end\n  end\n\n  def authenticate_with_previous_password_first(unencrypted_password)\n    if password_digest_changed?\n      BCrypt::Password.new(password_digest_was).is_password?(unencrypted_password) && self\n    else\n      authenticate(unencrypted_password)\n    end\n  end\n\n  def begin_password_reset(return_to = nil)\n    if Postal::Config.oidc.enabled? && (oidc_uid.present? || password_digest.blank?)\n      raise Postal::Error, \"User has OIDC enabled, password resets are not supported\"\n    end\n\n    self.password_reset_token = SecureRandom.alphanumeric(24)\n    self.password_reset_token_valid_until = 1.day.from_now\n    save!\n    AppMailer.password_reset(self, return_to).deliver\n  end\n\n  private\n\n  def clear_password_reset_token_on_password_change\n    return unless password_digest_changed?\n\n    self.password_reset_token = nil\n    self.password_reset_token_valid_until = nil\n  end\n\n  def validate_password_presence\n    return if password_digest.present? || Postal::Config.oidc.enabled?\n\n    errors.add :password, :blank\n  end\n\nend\n\n# -*- SkipSchemaAnnotations\n"
  },
  {
    "path": "app/models/concerns/has_dns_checks.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"resolv\"\n\nmodule HasDNSChecks\n\n  def dns_ok?\n    spf_status == \"OK\" && dkim_status == \"OK\" && %w[OK Missing].include?(mx_status) && %w[OK Missing].include?(return_path_status)\n  end\n\n  def dns_checked?\n    spf_status.present?\n  end\n\n  def check_dns(source = :manual)\n    check_spf_record\n    check_dkim_record\n    check_mx_records\n    check_return_path_record\n    self.dns_checked_at = Time.now\n    save!\n    if source == :auto && !dns_ok? && owner.is_a?(Server)\n      WebhookRequest.trigger(owner, \"DomainDNSError\", {\n        server: owner.webhook_hash,\n        domain: name,\n        uuid: uuid,\n        dns_checked_at: dns_checked_at.to_f,\n        spf_status: spf_status,\n        spf_error: spf_error,\n        dkim_status: dkim_status,\n        dkim_error: dkim_error,\n        mx_status: mx_status,\n        mx_error: mx_error,\n        return_path_status: return_path_status,\n        return_path_error: return_path_error\n      })\n    end\n    dns_ok?\n  end\n\n  #\n  # SPF\n  #\n\n  def check_spf_record\n    result = resolver.txt(name)\n    spf_records = result.grep(/\\Av=spf1/)\n    if spf_records.empty?\n      self.spf_status = \"Missing\"\n      self.spf_error = \"No SPF record exists for this domain\"\n    else\n      suitable_spf_records = spf_records.grep(/include:\\s*#{Regexp.escape(Postal::Config.dns.spf_include)}/)\n      if suitable_spf_records.empty?\n        self.spf_status = \"Invalid\"\n        self.spf_error = \"An SPF record exists but it doesn't include #{Postal::Config.dns.spf_include}\"\n        false\n      else\n        self.spf_status = \"OK\"\n        self.spf_error = nil\n        true\n      end\n    end\n  end\n\n  def check_spf_record!\n    check_spf_record\n    save!\n  end\n\n  #\n  # DKIM\n  #\n\n  def check_dkim_record\n    domain = \"#{dkim_record_name}.#{name}\"\n    records = resolver.txt(domain)\n    if records.empty?\n      self.dkim_status = \"Missing\"\n      self.dkim_error = \"No TXT records were returned for #{domain}\"\n    else\n      sanitised_dkim_record = records.first.strip.ends_with?(\";\") ? records.first.strip : \"#{records.first.strip};\"\n      if records.size > 1\n        self.dkim_status = \"Invalid\"\n        self.dkim_error = \"There are #{records.size} records for at #{domain}. There should only be one.\"\n      elsif sanitised_dkim_record != dkim_record\n        self.dkim_status = \"Invalid\"\n        self.dkim_error = \"The DKIM record at #{domain} does not match the record we have provided. Please check it has been copied correctly.\"\n      else\n        self.dkim_status = \"OK\"\n        self.dkim_error = nil\n        true\n      end\n    end\n  end\n\n  def check_dkim_record!\n    check_dkim_record\n    save!\n  end\n\n  #\n  # MX\n  #\n\n  def check_mx_records\n    records = resolver.mx(name).map(&:last)\n    if records.empty?\n      self.mx_status = \"Missing\"\n      self.mx_error = \"There are no MX records for #{name}\"\n    else\n      missing_records = Postal::Config.dns.mx_records.dup - records.map { |r| r.to_s.downcase }\n      if missing_records.empty?\n        self.mx_status = \"OK\"\n        self.mx_error = nil\n      elsif missing_records.size == Postal::Config.dns.mx_records.size\n        self.mx_status = \"Missing\"\n        self.mx_error = \"You have MX records but none of them point to us.\"\n      else\n        self.mx_status = \"Invalid\"\n        self.mx_error = \"MX #{missing_records.size == 1 ? 'record' : 'records'} for #{missing_records.to_sentence} are missing and are required.\"\n      end\n    end\n  end\n\n  def check_mx_records!\n    check_mx_records\n    save!\n  end\n\n  #\n  # Return Path\n  #\n\n  def check_return_path_record\n    records = resolver.cname(return_path_domain)\n    if records.empty?\n      self.return_path_status = \"Missing\"\n      self.return_path_error = \"There is no return path record at #{return_path_domain}\"\n    elsif records.size == 1 && records.first == Postal::Config.dns.return_path_domain\n      self.return_path_status = \"OK\"\n      self.return_path_error = nil\n    else\n      self.return_path_status = \"Invalid\"\n      self.return_path_error = \"There is a CNAME record at #{return_path_domain} but it points to #{records.first} which is incorrect. It should point to #{Postal::Config.dns.return_path_domain}.\"\n    end\n  end\n\n  def check_return_path_record!\n    check_return_path_record\n    save!\n  end\n\nend\n\n# -*- SkipSchemaAnnotations\n"
  },
  {
    "path": "app/models/concerns/has_locking.rb",
    "content": "# frozen_string_literal: true\n\n# This concern provides functionality for locking items along with additional functionality to handle\n# the concept of retrying items after a certain period of time. The following database columns are\n# required on the model\n#\n# * locked_by - A string column to store the name of the process that has locked the item\n# * locked_at - A datetime column to store the time the item was locked\n# * retry_after - A datetime column to store the time after which the item should be retried\n# * attempts - An integer column to store the number of attempts that have been made to process the item\n#\n# 'ready' means that it's ready to be processed.\nmodule HasLocking\n\n  extend ActiveSupport::Concern\n\n  included do\n    scope :unlocked, -> { where(locked_at: nil) }\n    scope :ready, -> { where(\"retry_after IS NULL OR retry_after < ?\", Time.now) }\n  end\n\n  def ready?\n    retry_after.nil? || retry_after < Time.now\n  end\n\n  def unlock\n    self.locked_by = nil\n    self.locked_at = nil\n    update_columns(locked_by: nil, locked_at: nil)\n  end\n\n  def locked?\n    locked_at.present?\n  end\n\n  def retry_later(time = nil)\n    retry_time = time || calculate_retry_time(attempts, 5.minutes)\n    self.locked_by = nil\n    self.locked_at = nil\n    update_columns(locked_by: nil, locked_at: nil, retry_after: Time.now + retry_time, attempts: attempts + 1)\n  end\n\n  def calculate_retry_time(attempts, initial_period)\n    (1.3**attempts) * initial_period\n  end\n\nend\n"
  },
  {
    "path": "app/models/concerns/has_message.rb",
    "content": "# frozen_string_literal: true\n\nmodule HasMessage\n\n  def self.included(base)\n    base.extend ClassMethods\n  end\n\n  def message\n    return @message if instance_variable_defined?(\"@message\")\n\n    @message = server.message_db.message(message_id)\n  rescue Postal::MessageDB::Message::NotFound\n    @message = nil\n  end\n\n  def message=(message)\n    @message = message\n    self.message_id = message&.id\n  end\n\n  module ClassMethods\n\n    def include_message\n      queued_messages = all.to_a\n      server_ids = queued_messages.map(&:server_id).uniq\n      if server_ids.empty?\n        return []\n      elsif server_ids.size > 1\n        raise Postal::Error, \"'include_message' can only be used on collections of messages from the same server\"\n      end\n\n      message_ids = queued_messages.map(&:message_id).uniq\n      server = queued_messages.first&.server\n      messages = server.message_db.messages(where: { id: message_ids }).index_by do |message|\n        message.id\n      end\n      queued_messages.each do |queued_message|\n        if m = messages[queued_message.message_id]\n          queued_message.message = m\n        end\n      end\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/models/concerns/has_soft_destroy.rb",
    "content": "# frozen_string_literal: true\n\nmodule HasSoftDestroy\n\n  def self.included(base)\n    base.define_callbacks :soft_destroy\n    base.class_eval do\n      scope :deleted, -> { where.not(deleted_at: nil) }\n      scope :present, -> { where(deleted_at: nil) }\n    end\n  end\n\n  def soft_destroy\n    run_callbacks :soft_destroy do\n      self.deleted_at = Time.now\n      save!\n    end\n  end\n\nend\n"
  },
  {
    "path": "app/models/concerns/has_uuid.rb",
    "content": "# frozen_string_literal: true\n\nmodule HasUUID\n\n  def self.included(base)\n    base.class_eval do\n      random_string :uuid, type: :uuid, unique: true\n    end\n  end\n\n  def to_param\n    uuid\n  end\n\nend\n"
  },
  {
    "path": "app/models/credential.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: credentials\n#\n#  id           :integer          not null, primary key\n#  server_id    :integer\n#  key          :string(255)\n#  type         :string(255)\n#  name         :string(255)\n#  options      :text(65535)\n#  last_used_at :datetime\n#  created_at   :datetime\n#  updated_at   :datetime\n#  hold         :boolean          default(FALSE)\n#  uuid         :string(255)\n#\n\nclass Credential < ApplicationRecord\n\n  include HasUUID\n\n  belongs_to :server\n\n  TYPES = %w[SMTP API SMTP-IP].freeze\n\n  validates :key, presence: true, uniqueness: { case_sensitive: false }\n  validates :type, inclusion: { in: TYPES }\n  validates :name, presence: true\n  validate :validate_key_cannot_be_changed\n  validate :validate_key_for_smtp_ip\n\n  serialize :options, type: Hash\n\n  before_validation :generate_key\n\n  def generate_key\n    return if type == \"SMTP-IP\"\n    return if persisted?\n\n    self.key = SecureRandom.alphanumeric(24)\n  end\n\n  def to_param\n    uuid\n  end\n\n  def use\n    update_column(:last_used_at, Time.now)\n  end\n\n  def usage_type\n    if last_used_at.nil?\n      \"Unused\"\n    elsif last_used_at < 1.year.ago\n      \"Inactive\"\n    elsif last_used_at < 6.months.ago\n      \"Dormant\"\n    elsif last_used_at < 1.month.ago\n      \"Quiet\"\n    else\n      \"Active\"\n    end\n  end\n\n  def to_smtp_plain\n    Base64.encode64(\"\\0XX\\0#{key}\").strip\n  end\n\n  def ipaddr\n    return unless type == \"SMTP-IP\"\n\n    @ipaddr ||= IPAddr.new(key)\n  rescue IPAddr::InvalidAddressError\n    nil\n  end\n\n  private\n\n  def validate_key_cannot_be_changed\n    return if new_record?\n    return unless key_changed?\n    return if type == \"SMTP-IP\"\n\n    errors.add :key, \"cannot be changed\"\n  end\n\n  def validate_key_for_smtp_ip\n    return unless type == \"SMTP-IP\"\n\n    IPAddr.new(key.to_s)\n  rescue IPAddr::InvalidAddressError\n    errors.add :key, \"must be a valid IPv4 or IPv6 address\"\n  end\n\nend\n"
  },
  {
    "path": "app/models/domain.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: domains\n#\n#  id                     :integer          not null, primary key\n#  server_id              :integer\n#  uuid                   :string(255)\n#  name                   :string(255)\n#  verification_token     :string(255)\n#  verification_method    :string(255)\n#  verified_at            :datetime\n#  dkim_private_key       :text(65535)\n#  created_at             :datetime\n#  updated_at             :datetime\n#  dns_checked_at         :datetime\n#  spf_status             :string(255)\n#  spf_error              :string(255)\n#  dkim_status            :string(255)\n#  dkim_error             :string(255)\n#  mx_status              :string(255)\n#  mx_error               :string(255)\n#  return_path_status     :string(255)\n#  return_path_error      :string(255)\n#  outgoing               :boolean          default(TRUE)\n#  incoming               :boolean          default(TRUE)\n#  owner_type             :string(255)\n#  owner_id               :integer\n#  dkim_identifier_string :string(255)\n#  use_for_any            :boolean\n#\n# Indexes\n#\n#  index_domains_on_server_id  (server_id)\n#  index_domains_on_uuid       (uuid)\n#\n\nrequire \"resolv\"\n\nclass Domain < ApplicationRecord\n\n  include HasUUID\n\n  include HasDNSChecks\n\n  VERIFICATION_EMAIL_ALIASES = %w[webmaster postmaster admin administrator hostmaster].freeze\n  VERIFICATION_METHODS = %w[DNS Email].freeze\n\n  belongs_to :server, optional: true\n  belongs_to :owner, optional: true, polymorphic: true\n  has_many :routes, dependent: :destroy\n  has_many :track_domains, dependent: :destroy\n\n  validates :name, presence: true, format: { with: /\\A[a-z0-9\\-.]*\\z/ }, uniqueness: { case_sensitive: false, scope: [:owner_type, :owner_id], message: \"is already added\" }\n  validates :verification_method, inclusion: { in: VERIFICATION_METHODS }\n\n  random_string :dkim_identifier_string, type: :chars, length: 6, unique: true, upper_letters_only: true\n\n  before_create :generate_dkim_key\n\n  scope :verified, -> { where.not(verified_at: nil) }\n\n  before_save :update_verification_token_on_method_change\n\n  def verified?\n    verified_at.present?\n  end\n\n  def mark_as_verified\n    return false if verified?\n\n    self.verified_at = Time.now\n    save!\n  end\n\n  def parent_domains\n    parts = name.split(\".\")\n    parts[0, parts.size - 1].each_with_index.map do |_, i|\n      parts[i..].join(\".\")\n    end\n  end\n\n  def generate_dkim_key\n    self.dkim_private_key = OpenSSL::PKey::RSA.new(1024).to_s\n  end\n\n  def dkim_key\n    return nil unless dkim_private_key\n\n    @dkim_key ||= OpenSSL::PKey::RSA.new(dkim_private_key)\n  end\n\n  def to_param\n    uuid\n  end\n\n  def verification_email_addresses\n    parent_domains.map do |domain|\n      VERIFICATION_EMAIL_ALIASES.map do |a|\n        \"#{a}@#{domain}\"\n      end\n    end.flatten\n  end\n\n  def spf_record\n    \"v=spf1 a mx include:#{Postal::Config.dns.spf_include} ~all\"\n  end\n\n  def dkim_record\n    return if dkim_key.nil?\n\n    public_key = dkim_key.public_key.to_s.gsub(/-+[A-Z ]+-+\\n/, \"\").gsub(/\\n/, \"\")\n    \"v=DKIM1; t=s; h=sha256; p=#{public_key};\"\n  end\n\n  def dkim_identifier\n    return nil unless dkim_identifier_string\n\n    Postal::Config.dns.dkim_identifier + \"-#{dkim_identifier_string}\"\n  end\n\n  def dkim_record_name\n    identifier = dkim_identifier\n    return if identifier.nil?\n\n    \"#{identifier}._domainkey\"\n  end\n\n  def return_path_domain\n    \"#{Postal::Config.dns.custom_return_path_prefix}.#{name}\"\n  end\n\n  # Returns a DNSResolver instance that can be used to perform DNS lookups needed for\n  # the verification and DNS checking for this domain.\n  #\n  # @return [DNSResolver]\n  def resolver\n    return DNSResolver.local if Postal::Config.postal.use_local_ns_for_domain_verification?\n\n    @resolver ||= DNSResolver.for_domain(name)\n  end\n\n  def dns_verification_string\n    \"#{Postal::Config.dns.domain_verify_prefix} #{verification_token}\"\n  end\n\n  def verify_with_dns\n    return false unless verification_method == \"DNS\"\n\n    result = resolver.txt(name)\n\n    if result.include?(dns_verification_string)\n      self.verified_at = Time.now\n      return save\n    end\n\n    false\n  end\n\n  private\n\n  def update_verification_token_on_method_change\n    return unless verification_method_changed?\n\n    if verification_method == \"DNS\"\n      self.verification_token = SecureRandom.alphanumeric(32)\n    elsif verification_method == \"Email\"\n      self.verification_token = rand(999_999).to_s.ljust(6, \"0\")\n    else\n      self.verification_token = nil\n    end\n  end\n\nend\n"
  },
  {
    "path": "app/models/http_endpoint.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: http_endpoints\n#\n#  id                  :integer          not null, primary key\n#  server_id           :integer\n#  uuid                :string(255)\n#  name                :string(255)\n#  url                 :string(255)\n#  encoding            :string(255)\n#  format              :string(255)\n#  strip_replies       :boolean          default(FALSE)\n#  error               :text(65535)\n#  disabled_until      :datetime\n#  last_used_at        :datetime\n#  created_at          :datetime\n#  updated_at          :datetime\n#  include_attachments :boolean          default(TRUE)\n#  timeout             :integer\n#\n\nclass HTTPEndpoint < ApplicationRecord\n\n  DEFAULT_TIMEOUT = 5\n\n  include HasUUID\n\n  belongs_to :server\n  has_many :routes, as: :endpoint\n  has_many :additional_route_endpoints, dependent: :destroy, as: :endpoint\n\n  ENCODINGS = %w[BodyAsJSON FormData].freeze\n  FORMATS = %w[Hash RawMessage].freeze\n\n  before_destroy :update_routes\n\n  validates :name, presence: true\n  validates :url, presence: true\n  validates :encoding, inclusion: { in: ENCODINGS }\n  validates :format, inclusion: { in: FORMATS }\n  validates :timeout, numericality: { greater_than_or_equal_to: 5, less_than_or_equal_to: 60 }\n\n  default_value :timeout, -> { DEFAULT_TIMEOUT }\n\n  def description\n    \"#{name} (#{url})\"\n  end\n\n  def mark_as_used\n    update_column(:last_used_at, Time.now)\n  end\n\n  def update_routes\n    routes.each { |r| r.update(endpoint: nil, mode: \"Reject\") }\n  end\n\nend\n"
  },
  {
    "path": "app/models/incoming_message_prototype.rb",
    "content": "# frozen_string_literal: true\n\nclass IncomingMessagePrototype\n\n  attr_accessor :to\n  attr_accessor :from\n  attr_accessor :route_id\n  attr_accessor :subject\n  attr_accessor :plain_body\n  attr_accessor :attachments\n\n  def initialize(server, ip, source_type, attributes)\n    @server = server\n    @ip = ip\n    @source_type = source_type\n    @attachments = []\n    attributes.each do |key, value|\n      instance_variable_set(\"@#{key}\", value)\n    end\n  end\n\n  def from_address\n    @from.gsub(/.*</, \"\").gsub(/>.*/, \"\").strip\n  end\n\n  def route\n    @route ||= if @to.present?\n                 uname, domain = @to.split(\"@\", 2)\n                 uname, _tag = uname.split(\"+\", 2)\n                 @server.routes.includes(:domain).where(domains: { name: domain }, name: uname).first\n               end\n  end\n\n  # rubocop:disable Lint/DuplicateMethods\n  def attachments\n    (@attachments || []).map do |attachment|\n      {\n        name: attachment[:name],\n        content_type: attachment[:content_type] || \"application/octet-stream\",\n        data: attachment[:base64] ? Base64.decode64(attachment[:data]) : attachment[:data]\n      }\n    end\n  end\n  # rubocop:enable Lint/DuplicateMethods\n\n  def create_messages\n    if valid?\n      messages = route.create_messages do |message|\n        message.rcpt_to = @to\n        message.mail_from = from_address\n        message.raw_message = raw_message\n      end\n      { route.description => { id: messages.first.id, token: messages.first.token } }\n    else\n      false\n    end\n  end\n\n  def valid?\n    validate\n    errors.empty?\n  end\n\n  def errors\n    @errors || []\n  end\n\n  def validate\n    @errors = []\n    if route.nil?\n      @errors << \"NoRoutesFound\"\n    end\n\n    if from.empty?\n      @errors << \"FromAddressMissing\"\n    end\n\n    if subject.blank?\n      @errors << \"SubjectMissing\"\n    end\n    @errors\n  end\n\n  def raw_message\n    @raw_message ||= begin\n      mail = Mail.new\n      mail.to = @to\n      mail.from = @from\n      mail.subject = @subject\n      mail.text_part = @plain_body\n      mail.message_id = \"<#{SecureRandom.uuid}@#{Postal::Config.dns.return_path_domain}>\"\n      attachments.each do |attachment|\n        mail.attachments[attachment[:name]] = {\n          mime_type: attachment[:content_type],\n          content: attachment[:data]\n        }\n      end\n      mail.header[\"Received\"] = ReceivedHeader.generate(@server, @source_type, @ip, :http)\n      mail.to_s\n    end\n  end\n\nend\n"
  },
  {
    "path": "app/models/ip_address.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: ip_addresses\n#\n#  id         :integer          not null, primary key\n#  ip_pool_id :integer\n#  ipv4       :string(255)\n#  ipv6       :string(255)\n#  created_at :datetime\n#  updated_at :datetime\n#  hostname   :string(255)\n#  priority   :integer\n#\n\nclass IPAddress < ApplicationRecord\n\n  belongs_to :ip_pool\n\n  validates :ipv4, presence: true, uniqueness: true\n  validates :hostname, presence: true\n  validates :ipv6, uniqueness: { allow_blank: true }\n  validates :priority, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100, only_integer: true }\n\n  scope :order_by_priority, -> { order(priority: :desc) }\n\n  before_validation :set_default_priority\n\n  private\n\n  def set_default_priority\n    return if priority.present?\n\n    self.priority = 100\n  end\n\n  class << self\n\n    def select_by_priority\n      order(Arel.sql(\"RAND() * priority DESC\")).first\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/models/ip_pool.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: ip_pools\n#\n#  id         :integer          not null, primary key\n#  name       :string(255)\n#  uuid       :string(255)\n#  created_at :datetime\n#  updated_at :datetime\n#  default    :boolean          default(FALSE)\n#\n# Indexes\n#\n#  index_ip_pools_on_uuid  (uuid)\n#\n\nclass IPPool < ApplicationRecord\n\n  include HasUUID\n\n  validates :name, presence: true\n\n  has_many :ip_addresses, dependent: :restrict_with_exception\n  has_many :servers, dependent: :restrict_with_exception\n  has_many :organization_ip_pools, dependent: :destroy\n  has_many :organizations, through: :organization_ip_pools\n  has_many :ip_pool_rules, dependent: :destroy\n\n  def self.default\n    where(default: true).order(:id).first\n  end\n\nend\n"
  },
  {
    "path": "app/models/ip_pool_rule.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: ip_pool_rules\n#\n#  id         :integer          not null, primary key\n#  uuid       :string(255)\n#  owner_type :string(255)\n#  owner_id   :integer\n#  ip_pool_id :integer\n#  from_text  :text(65535)\n#  to_text    :text(65535)\n#  created_at :datetime         not null\n#  updated_at :datetime         not null\n#\n\nclass IPPoolRule < ApplicationRecord\n\n  include HasUUID\n\n  belongs_to :owner, polymorphic: true\n  belongs_to :ip_pool\n\n  validate :validate_from_and_to_addresses\n  validate :validate_ip_pool_belongs_to_organization\n\n  def from\n    from_text ? from_text.gsub(/\\r/, \"\").split(/\\n/).map(&:strip) : []\n  end\n\n  def to\n    to_text ? to_text.gsub(/\\r/, \"\").split(/\\n/).map(&:strip) : []\n  end\n\n  def apply_to_message?(message)\n    if from.present? && message.headers[\"from\"].present?\n      from.each do |condition|\n        if message.headers[\"from\"].any? { |f| self.class.address_matches?(condition, f) }\n          return true\n        end\n      end\n    end\n\n    if to.present? && message.rcpt_to.present?\n      to.each do |condition|\n        if self.class.address_matches?(condition, message.rcpt_to)\n          return true\n        end\n      end\n    end\n\n    false\n  end\n\n  private\n\n  def validate_from_and_to_addresses\n    return unless from.empty? && to.empty?\n\n    errors.add :base, \"At least one rule condition must be specified\"\n  end\n\n  def validate_ip_pool_belongs_to_organization\n    org = owner.is_a?(Organization) ? owner : owner.organization\n    return unless ip_pool && ip_pool_id_changed? && !org.ip_pools.include?(ip_pool)\n\n    errors.add :ip_pool_id, \"must belong to the organization\"\n  end\n\n  class << self\n\n    def address_matches?(condition, address)\n      address = Postal::Helpers.strip_name_from_address(address)\n      if condition =~ /@/\n        parts = address.split(\"@\")\n        domain = parts.pop\n        uname = parts.join(\"@\")\n        uname, = uname.split(\"+\", 2)\n        condition == \"#{uname}@#{domain}\"\n      else\n        # Match as a domain\n        condition == address.split(\"@\").last\n      end\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/models/organization.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: organizations\n#\n#  id                :integer          not null, primary key\n#  uuid              :string(255)\n#  name              :string(255)\n#  permalink         :string(255)\n#  time_zone         :string(255)\n#  created_at        :datetime\n#  updated_at        :datetime\n#  ip_pool_id        :integer\n#  owner_id          :integer\n#  deleted_at        :datetime\n#  suspended_at      :datetime\n#  suspension_reason :string(255)\n#\n# Indexes\n#\n#  index_organizations_on_permalink  (permalink)\n#  index_organizations_on_uuid       (uuid)\n#\n\nclass Organization < ApplicationRecord\n\n  RESERVED_PERMALINKS = %w[new edit remove delete destroy admin mail org server].freeze\n\n  INITIAL_QUOTA = 10\n  INITIAL_SUPER_QUOTA = 10_000\n  include HasUUID\n  include HasSoftDestroy\n\n  validates :name, presence: true\n  validates :permalink, presence: true, format: { with: /\\A[a-z0-9-]*\\z/ }, uniqueness: { case_sensitive: false }, exclusion: { in: RESERVED_PERMALINKS }\n  validates :time_zone, presence: true\n\n  default_value :time_zone, -> { \"UTC\" }\n  default_value :permalink, -> { Organization.find_unique_permalink(name) if name }\n\n  belongs_to :owner, class_name: \"User\"\n  has_many :organization_users, dependent: :destroy\n  has_many :users, through: :organization_users, source_type: \"User\"\n  has_many :user_invites, through: :organization_users, source_type: \"UserInvite\", source: :user\n  has_many :servers, dependent: :destroy\n  has_many :domains, as: :owner, dependent: :destroy\n  has_many :organization_ip_pools, dependent: :destroy\n  has_many :ip_pools, through: :organization_ip_pools\n  has_many :ip_pool_rules, dependent: :destroy, as: :owner\n\n  after_create do\n    if IPPool.default\n      ip_pools << IPPool.default\n    end\n  end\n\n  def status\n    if suspended?\n      \"Suspended\"\n    else\n      \"Active\"\n    end\n  end\n\n  def to_param\n    permalink\n  end\n\n  def suspended?\n    suspended_at.present?\n  end\n\n  def user_assignment(user)\n    @user_assignments ||= {}\n    @user_assignments[user.id] ||= organization_users.where(user: user).first\n  end\n\n  def make_owner(new_owner)\n    user_assignment(new_owner).update(admin: true, all_servers: true)\n    update(owner: new_owner)\n  end\n\n  # This is an array of addresses that should receive notifications for this organization\n  def notification_addresses\n    users.map(&:email_tag)\n  end\n\n  def self.find_unique_permalink(name)\n    loop.each_with_index do |_, i|\n      i += 1\n      proposal = name.parameterize\n      proposal += \"-#{i}\" if i > 1\n      unless where(permalink: proposal).exists?\n        return proposal\n      end\n    end\n  end\n\n  def self.[](id)\n    if id.is_a?(String)\n      where(permalink: id).first\n    else\n      where(id: id.to_i).first\n    end\n  end\n\nend\n"
  },
  {
    "path": "app/models/organization_ip_pool.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: organization_ip_pools\n#\n#  id              :integer          not null, primary key\n#  organization_id :integer\n#  ip_pool_id      :integer\n#  created_at      :datetime         not null\n#  updated_at      :datetime         not null\n#\n\nclass OrganizationIPPool < ApplicationRecord\n\n  belongs_to :organization\n  belongs_to :ip_pool\n\nend\n"
  },
  {
    "path": "app/models/organization_user.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: organization_users\n#\n#  id              :integer          not null, primary key\n#  organization_id :integer\n#  user_id         :integer\n#  created_at      :datetime\n#  admin           :boolean          default(FALSE)\n#  all_servers     :boolean          default(TRUE)\n#  user_type       :string(255)\n#\n\nclass OrganizationUser < ApplicationRecord\n\n  belongs_to :organization\n  belongs_to :user, polymorphic: true, optional: true\n\nend\n"
  },
  {
    "path": "app/models/outgoing_message_prototype.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"resolv\"\n\nclass OutgoingMessagePrototype\n\n  attr_accessor :from\n  attr_accessor :sender\n  attr_accessor :to\n  attr_accessor :cc\n  attr_accessor :bcc\n  attr_accessor :subject\n  attr_accessor :reply_to\n  attr_accessor :custom_headers\n  attr_accessor :plain_body\n  attr_accessor :html_body\n  attr_accessor :attachments\n  attr_accessor :tag\n  attr_accessor :credential\n  attr_accessor :bounce\n\n  def initialize(server, ip, source_type, attributes)\n    @server = server\n    @ip = ip\n    @source_type = source_type\n    @custom_headers = {}\n    @attachments = []\n    @message_id = \"#{SecureRandom.uuid}@#{Postal::Config.dns.return_path_domain}\"\n    attributes.each do |key, value|\n      instance_variable_set(\"@#{key}\", value)\n    end\n  end\n\n  attr_reader :message_id\n\n  def from_address\n    Postal::Helpers.strip_name_from_address(@from)\n  end\n\n  def sender_address\n    Postal::Helpers.strip_name_from_address(@sender)\n  end\n\n  def domain\n    @domain ||= begin\n      d = find_domain\n      d == :none ? nil : d\n    end\n  end\n\n  def find_domain\n    domain = @server.authenticated_domain_for_address(@from)\n    if @server.allow_sender? && domain.nil?\n      domain = @server.authenticated_domain_for_address(@sender)\n    end\n    domain || :none\n  end\n\n  def to_addresses\n    @to.is_a?(String) ? @to.to_s.split(/,\\s*/) : @to.to_a\n  end\n\n  def cc_addresses\n    @cc.is_a?(String) ? @cc.to_s.split(/,\\s*/) : @cc.to_a\n  end\n\n  def bcc_addresses\n    @bcc.is_a?(String) ? @bcc.to_s.split(/,\\s*/) : @bcc.to_a\n  end\n\n  def all_addresses\n    [to_addresses, cc_addresses, bcc_addresses].flatten\n  end\n\n  def create_messages\n    if valid?\n      all_addresses.each_with_object({}) do |address, hash|\n        if address = Postal::Helpers.strip_name_from_address(address)\n          hash[address] = create_message(address)\n        end\n      end\n    else\n      false\n    end\n  end\n\n  def valid?\n    validate\n    errors.empty?\n  end\n\n  def errors\n    @errors || {}\n  end\n\n  # rubocop:disable Lint/DuplicateMethods\n  def attachments\n    (@attachments || []).map do |attachment|\n      {\n        name: attachment[:name],\n        content_type: attachment[:content_type] || \"application/octet-stream\",\n        data: attachment[:base64] && attachment[:data] ? Base64.decode64(attachment[:data]) : attachment[:data]\n      }\n    end\n  end\n  # rubocop:enable Lint/DuplicateMethods\n\n  def validate\n    @errors = []\n\n    if to_addresses.empty? && cc_addresses.empty? && bcc_addresses.empty?\n      @errors << \"NoRecipients\"\n    end\n\n    if to_addresses.size > 50\n      @errors << \"TooManyToAddresses\"\n    end\n\n    if cc_addresses.size > 50\n      @errors << \"TooManyCCAddresses\"\n    end\n\n    if bcc_addresses.size > 50\n      @errors << \"TooManyBCCAddresses\"\n    end\n\n    if @plain_body.blank? && @html_body.blank?\n      @errors << \"NoContent\"\n    end\n\n    if from.blank?\n      @errors << \"FromAddressMissing\"\n    end\n\n    if domain.nil?\n      @errors << \"UnauthenticatedFromAddress\"\n    end\n\n    if attachments.present?\n      attachments.each do |attachment|\n        if attachment[:name].blank?\n          @errors << \"AttachmentMissingName\" unless @errors.include?(\"AttachmentMissingName\")\n        elsif attachment[:data].blank?\n          @errors << \"AttachmentMissingData\" unless @errors.include?(\"AttachmentMissingData\")\n        end\n      end\n    end\n    @errors\n  end\n\n  def raw_message\n    @raw_message ||= begin\n      mail = Mail.new\n      if @custom_headers.is_a?(Hash)\n        @custom_headers.each { |key, value| mail[key.to_s] = value.to_s }\n      end\n      mail.to = to_addresses.join(\", \") if to_addresses.present?\n      mail.cc = cc_addresses.join(\", \") if cc_addresses.present?\n      mail.from = @from\n      mail.sender = @sender\n      mail.subject = @subject\n      mail.reply_to = @reply_to\n      mail.part content_type: \"multipart/alternative\" do |p|\n        if @plain_body.present?\n          p.text_part = Mail::Part.new\n          p.text_part.body = @plain_body\n        end\n        if @html_body.present?\n          p.html_part = Mail::Part.new\n          p.html_part.content_type = \"text/html; charset=UTF-8\"\n          p.html_part.body = @html_body\n        end\n      end\n      attachments.each do |attachment|\n        mail.attachments[attachment[:name]] = {\n          mime_type: attachment[:content_type],\n          content: attachment[:data]\n        }\n      end\n      mail.header[\"Received\"] = ReceivedHeader.generate(@server, @source_type, @ip, :http)\n      mail.message_id = \"<#{@message_id}>\"\n      mail.to_s\n    end\n  end\n\n  def create_message(address)\n    message = @server.message_db.new_message\n    message.scope = \"outgoing\"\n    message.rcpt_to = address\n    message.mail_from = from_address\n    message.domain_id = domain.id\n    message.raw_message = raw_message\n    message.tag = tag\n    message.credential_id = credential&.id\n    message.received_with_ssl = true\n    message.bounce = @bounce\n    message.save\n    { id: message.id, token: message.token }\n  end\n\nend\n"
  },
  {
    "path": "app/models/queued_message.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: queued_messages\n#\n#  id            :integer          not null, primary key\n#  server_id     :integer\n#  message_id    :integer\n#  domain        :string(255)\n#  locked_by     :string(255)\n#  locked_at     :datetime\n#  retry_after   :datetime\n#  created_at    :datetime\n#  updated_at    :datetime\n#  ip_address_id :integer\n#  attempts      :integer          default(0)\n#  route_id      :integer\n#  manual        :boolean          default(FALSE)\n#  batch_key     :string(255)\n#\n# Indexes\n#\n#  index_queued_messages_on_domain      (domain)\n#  index_queued_messages_on_message_id  (message_id)\n#  index_queued_messages_on_server_id   (server_id)\n#\n\nclass QueuedMessage < ApplicationRecord\n\n  include HasMessage\n  include HasLocking\n\n  belongs_to :server\n  belongs_to :ip_address, optional: true\n\n  before_create :allocate_ip_address\n\n  scope :ready_with_delayed_retry, -> { where(\"retry_after IS NULL OR retry_after < ?\", 30.seconds.ago) }\n  scope :with_stale_lock, -> { where(\"locked_at IS NOT NULL AND locked_at < ?\", Postal::Config.postal.queued_message_lock_stale_days.days.ago) }\n\n  def retry_now\n    update!(retry_after: nil)\n  end\n\n  def send_bounce\n    return unless message.send_bounces?\n\n    BounceMessage.new(server, message).queue\n  end\n\n  def allocate_ip_address\n    return unless Postal.ip_pools?\n    return if message.nil?\n\n    pool = server.ip_pool_for_message(message)\n    return if pool.nil?\n\n    self.ip_address = pool.ip_addresses.select_by_priority\n  end\n\n  def batchable_messages(limit = 10)\n    unless locked?\n      raise Postal::Error, \"Must lock current message before locking any friends\"\n    end\n\n    if batch_key.nil?\n      []\n    else\n      time = Time.now\n      locker = Postal.locker_name\n      self.class.ready.where(batch_key: batch_key, ip_address_id: ip_address_id, locked_by: nil, locked_at: nil).limit(limit).update_all(locked_by: locker, locked_at: time)\n      QueuedMessage.where(batch_key: batch_key, ip_address_id: ip_address_id, locked_by: locker, locked_at: time).where.not(id: id)\n    end\n  end\n\nend\n"
  },
  {
    "path": "app/models/route.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: routes\n#\n#  id            :integer          not null, primary key\n#  uuid          :string(255)\n#  server_id     :integer\n#  domain_id     :integer\n#  endpoint_id   :integer\n#  endpoint_type :string(255)\n#  name          :string(255)\n#  spam_mode     :string(255)\n#  created_at    :datetime\n#  updated_at    :datetime\n#  token         :string(255)\n#  mode          :string(255)\n#\n# Indexes\n#\n#  index_routes_on_token  (token)\n#\n\nclass Route < ApplicationRecord\n\n  MODES = %w[Endpoint Accept Hold Bounce Reject].freeze\n  SPAM_MODES = %w[Mark Quarantine Fail].freeze\n  ENDPOINT_TYPES = %w[SMTPEndpoint HTTPEndpoint AddressEndpoint].freeze\n\n  include HasUUID\n\n  belongs_to :server\n  belongs_to :domain, optional: true\n  belongs_to :endpoint, polymorphic: true, optional: true\n  has_many :additional_route_endpoints, dependent: :destroy\n\n  validates :name, presence: true, format: /\\A(([a-z0-9\\-.]*)|(\\*)|(__returnpath__))\\z/\n  validates :spam_mode, inclusion: { in: SPAM_MODES }\n  validates :endpoint, presence: { if: proc { mode == \"Endpoint\" } }\n  validates :domain_id, presence: { unless: :return_path? }\n  validate :validate_route_is_routed\n  validate :validate_domain_belongs_to_server\n  validate :validate_endpoint_belongs_to_server\n  validate :validate_name_uniqueness\n  validate :validate_return_path_route_endpoints\n  validate :validate_no_additional_routes_on_non_endpoint_route\n\n  after_save :save_additional_route_endpoints\n\n  random_string :token, type: :chars, length: 8, unique: true\n\n  def return_path?\n    name == \"__returnpath__\"\n  end\n\n  def description\n    if return_path?\n      \"Return Path\"\n    else\n      \"#{name}@#{domain.name}\"\n    end\n  end\n\n  def _endpoint\n    if mode == \"Endpoint\"\n      @endpoint ||= endpoint ? \"#{endpoint.class}##{endpoint.uuid}\" : nil\n    else\n      @endpoint ||= mode\n    end\n  end\n\n  def _endpoint=(value)\n    if value.blank?\n      self.endpoint = nil\n      self.mode = nil\n    elsif value =~ /\\#/\n      class_name, id = value.split(\"#\", 2)\n      unless ENDPOINT_TYPES.include?(class_name)\n        raise Postal::Error, \"Invalid endpoint class name '#{class_name}'\"\n      end\n\n      self.endpoint = class_name.constantize.find_by_uuid(id)\n      self.mode = \"Endpoint\"\n    else\n      self.endpoint = nil\n      self.mode = value\n    end\n  end\n\n  def forward_address\n    @forward_address ||= \"#{token}@#{Postal::Config.dns.route_domain}\"\n  end\n\n  def wildcard?\n    name == \"*\"\n  end\n\n  def additional_route_endpoints_array\n    @additional_route_endpoints_array ||= additional_route_endpoints.map(&:_endpoint)\n  end\n\n  def additional_route_endpoints_array=(array)\n    @additional_route_endpoints_array = array.reject(&:blank?)\n  end\n\n  def save_additional_route_endpoints\n    return unless @additional_route_endpoints_array\n\n    seen = []\n    @additional_route_endpoints_array.each do |item|\n      if existing = additional_route_endpoints.find_by_endpoint(item)\n        seen << existing.id\n      else\n        route = additional_route_endpoints.build(_endpoint: item)\n        if route.save\n          seen << route.id\n        else\n          route.errors.each do |_, message|\n            errors.add :base, message\n          end\n          raise ActiveRecord::RecordInvalid\n        end\n      end\n    end\n    additional_route_endpoints.where.not(id: seen).destroy_all\n  end\n\n  #\n  # This message will create a suitable number of message objects for messages that\n  # are destined for this route. It receives a block which can set the message content\n  # but most information is specified already.\n  #\n  # Returns an array of created messages.\n  #\n  def create_messages(&block)\n    messages = []\n    message = build_message\n    if mode == \"Endpoint\" && server.message_db.schema_version >= 18\n      message.endpoint_type = endpoint_type\n      message.endpoint_id = endpoint_id\n    end\n    block.call(message)\n    message.save\n    messages << message\n\n    # Also create any messages for additional endpoints that might exist\n    if mode == \"Endpoint\" && server.message_db.schema_version >= 18\n      additional_route_endpoints.each do |endpoint|\n        next unless endpoint.endpoint\n\n        message = build_message\n        message.endpoint_id = endpoint.endpoint_id\n        message.endpoint_type = endpoint.endpoint_type\n        block.call(message)\n        message.save\n        messages << message\n      end\n    end\n\n    messages\n  end\n\n  def build_message\n    message = server.message_db.new_message\n    message.scope = \"incoming\"\n    message.rcpt_to = description\n    message.domain_id = domain&.id\n    message.route_id = id\n    message\n  end\n\n  private\n\n  def validate_route_is_routed\n    return unless mode.nil?\n\n    errors.add :endpoint, \"must be chosen\"\n  end\n\n  def validate_domain_belongs_to_server\n    if domain && ![server, server.organization].include?(domain.owner)\n      errors.add :domain, :invalid\n    end\n\n    return unless domain && !domain.verified?\n\n    errors.add :domain, \"has not been verified yet\"\n  end\n\n  def validate_endpoint_belongs_to_server\n    return unless endpoint && endpoint&.server != server\n\n    errors.add :endpoint, :invalid\n  end\n\n  def validate_name_uniqueness\n    return if server.nil?\n\n    if domain\n      if route = Route.includes(:domain).where(domains: { name: domain.name }, name: name).where.not(id: id).first\n        errors.add :name, \"is configured on the #{route.server.full_permalink} mail server\"\n      end\n    elsif Route.where(name: \"__returnpath__\").where.not(id: id).exists?\n      errors.add :base, \"A return path route already exists for this server\"\n    end\n  end\n\n  def validate_return_path_route_endpoints\n    return unless return_path?\n    return unless mode != \"Endpoint\" || endpoint_type != \"HTTPEndpoint\"\n\n    errors.add :base, \"Return path routes must point to an HTTP endpoint\"\n  end\n\n  def validate_no_additional_routes_on_non_endpoint_route\n    return unless mode != \"Endpoint\" && !additional_route_endpoints_array.empty?\n\n    errors.add :base, \"Additional routes are not permitted unless the primary route is an actual endpoint\"\n  end\n\n  class << self\n\n    def find_by_name_and_domain(name, domain)\n      route = Route.includes(:domain).where(name: name, domains: { name: domain }).first\n      if route.nil?\n        route = Route.includes(:domain).where(name: \"*\", domains: { name: domain }).first\n      end\n      route\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/models/scheduled_task.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: scheduled_tasks\n#\n#  id             :bigint           not null, primary key\n#  name           :string(255)\n#  next_run_after :datetime\n#\n# Indexes\n#\n#  index_scheduled_tasks_on_name  (name) UNIQUE\n#\nclass ScheduledTask < ApplicationRecord\nend\n"
  },
  {
    "path": "app/models/server.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: servers\n#\n#  id                                 :integer          not null, primary key\n#  allow_sender                       :boolean          default(FALSE)\n#  deleted_at                         :datetime\n#  domains_not_to_click_track         :text(65535)\n#  log_smtp_data                      :boolean          default(FALSE)\n#  message_retention_days             :integer\n#  mode                               :string(255)\n#  name                               :string(255)\n#  outbound_spam_threshold            :decimal(8, 2)\n#  permalink                          :string(255)\n#  postmaster_address                 :string(255)\n#  privacy_mode                       :boolean          default(FALSE)\n#  raw_message_retention_days         :integer\n#  raw_message_retention_size         :integer\n#  send_limit                         :integer\n#  send_limit_approaching_at          :datetime\n#  send_limit_approaching_notified_at :datetime\n#  send_limit_exceeded_at             :datetime\n#  send_limit_exceeded_notified_at    :datetime\n#  spam_failure_threshold             :decimal(8, 2)\n#  spam_threshold                     :decimal(8, 2)\n#  suspended_at                       :datetime\n#  suspension_reason                  :string(255)\n#  token                              :string(255)\n#  uuid                               :string(255)\n#  created_at                         :datetime\n#  updated_at                         :datetime\n#  ip_pool_id                         :integer\n#  organization_id                    :integer\n#\n# Indexes\n#\n#  index_servers_on_organization_id  (organization_id)\n#  index_servers_on_permalink        (permalink)\n#  index_servers_on_token            (token)\n#  index_servers_on_uuid             (uuid)\n#\n\nclass Server < ApplicationRecord\n\n  RESERVED_PERMALINKS = %w[new all search stats edit manage delete destroy remove].freeze\n  MODES = %w[Live Development].freeze\n\n  include HasUUID\n  include HasSoftDestroy\n\n  attr_accessor :provision_database\n\n  belongs_to :organization\n  belongs_to :ip_pool, optional: true\n  has_many :domains, dependent: :destroy, as: :owner\n  has_many :credentials, dependent: :destroy\n  has_many :smtp_endpoints, dependent: :destroy\n  has_many :http_endpoints, dependent: :destroy\n  has_many :address_endpoints, dependent: :destroy\n  has_many :routes, dependent: :destroy\n  has_many :queued_messages, dependent: :delete_all\n  has_many :webhooks, dependent: :destroy\n  has_many :webhook_requests, dependent: :destroy\n  has_many :track_domains, dependent: :destroy\n  has_many :ip_pool_rules, dependent: :destroy, as: :owner\n\n  random_string :token, type: :chars, length: 6, unique: true, upper_letters_only: true\n  default_value :permalink, -> { name ? name.parameterize : nil }\n  default_value :raw_message_retention_days, -> { 30 }\n  default_value :raw_message_retention_size, -> { 2048 }\n  default_value :message_retention_days, -> { 60 }\n  default_value :spam_threshold, -> { Postal::Config.postal.default_spam_threshold }\n  default_value :spam_failure_threshold, -> { Postal::Config.postal.default_spam_failure_threshold }\n\n  validates :name, presence: true, uniqueness: { scope: :organization_id, case_sensitive: false }\n  validates :mode, inclusion: { in: MODES }\n  validates :permalink, presence: true, uniqueness: { scope: :organization_id, case_sensitive: false }, format: { with: /\\A[a-z0-9-]*\\z/ }, exclusion: { in: RESERVED_PERMALINKS }\n  validate :validate_ip_pool_belongs_to_organization\n\n  before_validation(on: :create) do\n    self.token = token.downcase if token\n  end\n\n  after_create do\n    unless provision_database == false\n      message_db.provisioner.provision\n    end\n  end\n\n  after_commit(on: :destroy) do\n    unless provision_database == false\n      message_db.provisioner.drop\n    end\n  end\n\n  def status\n    if suspended?\n      \"Suspended\"\n    else\n      mode\n    end\n  end\n\n  def full_permalink\n    \"#{organization.permalink}/#{permalink}\"\n  end\n\n  def suspended?\n    suspended_at.present? || organization.suspended?\n  end\n\n  def actual_suspension_reason\n    return unless suspended?\n\n    if suspended_at.nil?\n      organization.suspension_reason\n    else\n      suspension_reason\n    end\n  end\n\n  def to_param\n    permalink\n  end\n\n  def message_db\n    @message_db ||= Postal::MessageDB::Database.new(organization_id, id)\n  end\n\n  delegate :message, to: :message_db\n\n  def message_rate\n    @message_rate ||= message_db.live_stats.total(60, types: [:incoming, :outgoing]) / 60.0\n  end\n\n  def held_messages\n    @held_messages ||= message_db.messages(where: { held: true }, count: true)\n  end\n\n  def throughput_stats\n    @throughput_stats ||= begin\n      incoming = message_db.live_stats.total(60, types: [:incoming])\n      outgoing = message_db.live_stats.total(60, types: [:outgoing])\n      outgoing_usage = send_limit ? (outgoing / send_limit.to_f) * 100 : 0\n      {\n        incoming: incoming,\n        outgoing: outgoing,\n        outgoing_usage: outgoing_usage\n      }\n    end\n  end\n\n  def bounce_rate\n    @bounce_rate ||= begin\n      time = Time.now.utc\n      total_outgoing = 0.0\n      total_bounces = 0.0\n      message_db.statistics.get(:daily, [:outgoing, :bounces], time, 30).each do |_, stat|\n        total_outgoing += stat[:outgoing]\n        total_bounces += stat[:bounces]\n      end\n      total_outgoing.zero? ? 0 : (total_bounces / total_outgoing) * 100\n    end\n  end\n\n  def domain_stats\n    domains = Domain.where(owner_id: id, owner_type: \"Server\").to_a\n    total = 0\n    unverified = 0\n    bad_dns = 0\n    domains.each do |domain|\n      total += 1\n      unverified += 1 unless domain.verified?\n      bad_dns += 1 if domain.verified? && !domain.dns_ok?\n    end\n    [total, unverified, bad_dns]\n  end\n\n  def webhook_hash\n    {\n      uuid: uuid,\n      name: name,\n      permalink: permalink,\n      organization: organization&.permalink\n    }\n  end\n\n  def send_volume\n    @send_volume ||= message_db.live_stats.total(60, types: [:outgoing])\n  end\n\n  def send_limit_approaching?\n    return false unless send_limit\n\n    (send_volume >= send_limit * 0.90)\n  end\n\n  def send_limit_exceeded?\n    return false unless send_limit\n\n    send_volume >= send_limit\n  end\n\n  def send_limit_warning(type)\n    if organization.notification_addresses.present?\n      AppMailer.send(\"server_send_limit_#{type}\", self).deliver\n    end\n\n    update_column(\"send_limit_#{type}_notified_at\", Time.now)\n    WebhookRequest.trigger(self, \"SendLimit#{type.to_s.capitalize}\", server: webhook_hash, volume: send_volume, limit: send_limit)\n  end\n\n  def queue_size\n    @queue_size ||= queued_messages.ready.count\n  end\n\n  # Return the domain which can be used to authenticate emails sent from the given e-mail address.\n  #\n  # @param address [String] an e-mail address\n  # @return [Domain, nil] the domain to use for authentication\n  def authenticated_domain_for_address(address)\n    return nil if address.blank?\n\n    address = Postal::Helpers.strip_name_from_address(address)\n    uname, domain_name = address.split(\"@\", 2)\n    return nil unless uname\n    return nil unless domain_name\n\n    # Find a verified domain which directly matches the domain name for the given address.\n    domain = Domain.verified\n                   .order(owner_type: :desc)\n                   .where(\"(owner_type = 'Organization' AND owner_id = ?) OR \" \\\n                          \"(owner_type = 'Server' AND owner_id = ?)\", organization_id, id)\n                   .where(name: domain_name)\n                   .first\n\n    # If there is a matching domain, return it\n    return domain if domain\n\n    # Otherwise, we need to look to see if there is a domain configured which can be used as the authenticated\n    # domain for any domain. This will look for domains directly within the server and return that.\n    any_domain = domains.verified.where(use_for_any: true).order(:name).first\n    return any_domain if any_domain\n\n    # Return nil if we can't find anything suitable\n    nil\n  end\n\n  def find_authenticated_domain_from_headers(headers)\n    header_to_check = [\"from\"]\n    header_to_check << \"sender\" if allow_sender?\n    header_to_check.each do |header_name|\n      if headers[header_name].is_a?(Array)\n        values = headers[header_name]\n      else\n        values = [headers[header_name].to_s]\n      end\n\n      authenticated_domains = values.map { |v| authenticated_domain_for_address(v) }.compact\n      if authenticated_domains.size == values.size\n        return authenticated_domains.first\n      end\n    end\n    nil\n  end\n\n  def suspend(reason)\n    self.suspended_at = Time.now\n    self.suspension_reason = reason\n    save!\n    if organization.notification_addresses.present?\n      AppMailer.server_suspended(self).deliver\n    end\n    true\n  end\n\n  def unsuspend\n    self.suspended_at = nil\n    self.suspension_reason = nil\n    save!\n  end\n\n  def ip_pool_for_message(message)\n    return unless message.scope == \"outgoing\"\n\n    [self, organization].each do |scope|\n      rules = scope.ip_pool_rules.order(created_at: :desc)\n      rules.each do |rule|\n        if rule.apply_to_message?(message)\n          return rule.ip_pool\n        end\n      end\n    end\n\n    ip_pool\n  end\n\n  private\n\n  def validate_ip_pool_belongs_to_organization\n    return unless ip_pool && ip_pool_id_changed? && !organization.ip_pools.include?(ip_pool)\n\n    errors.add :ip_pool_id, \"must belong to the organization\"\n  end\n\n  class << self\n\n    def triggered_send_limit(type)\n      servers = where(\"send_limit_#{type}_at IS NOT NULL AND send_limit_#{type}_at > ?\", 3.minutes.ago)\n      servers.where(\"send_limit_#{type}_notified_at IS NULL OR send_limit_#{type}_notified_at < ?\", 1.hour.ago)\n    end\n\n    def send_send_limit_notifications\n      [:approaching, :exceeded].each_with_object({}) do |type, hash|\n        hash[type] = 0\n        servers = triggered_send_limit(type)\n        next if servers.empty?\n\n        servers.each do |server|\n          hash[type] += 1\n          server.send_limit_warning(type)\n        end\n      end\n    end\n\n    def [](id, extra = nil)\n      if id.is_a?(String) && id =~ /\\A(\\w+)\\/(\\w+)\\z/\n        joins(:organization).where(\n          organizations: { permalink: ::Regexp.last_match(1) }, permalink: ::Regexp.last_match(2)\n        ).first\n      else\n        find_by(id: id.to_i)\n      end\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/models/smtp_endpoint.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: smtp_endpoints\n#\n#  id             :integer          not null, primary key\n#  server_id      :integer\n#  uuid           :string(255)\n#  name           :string(255)\n#  hostname       :string(255)\n#  ssl_mode       :string(255)\n#  port           :integer\n#  error          :text(65535)\n#  disabled_until :datetime\n#  last_used_at   :datetime\n#  created_at     :datetime\n#  updated_at     :datetime\n#\n\nclass SMTPEndpoint < ApplicationRecord\n\n  include HasUUID\n\n  belongs_to :server\n  has_many :routes, as: :endpoint\n  has_many :additional_route_endpoints, dependent: :destroy, as: :endpoint\n\n  SSL_MODES = %w[None Auto STARTTLS TLS].freeze\n\n  before_destroy :update_routes\n\n  validates :name, presence: true\n  validates :hostname, presence: true, format: /\\A[a-z0-9.-]*\\z/\n  validates :ssl_mode, inclusion: { in: SSL_MODES }\n  validates :port, numericality: { only_integer: true, allow_blank: true }\n\n  def description\n    \"#{name} (#{hostname})\"\n  end\n\n  def mark_as_used\n    update_column(:last_used_at, Time.now)\n  end\n\n  def update_routes\n    routes.each { |r| r.update(endpoint: nil, mode: \"Reject\") }\n  end\n\n  def to_smtp_client_server\n    SMTPClient::Server.new(hostname, port: port || 25, ssl_mode: ssl_mode)\n  end\n\nend\n"
  },
  {
    "path": "app/models/statistic.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: statistics\n#\n#  id             :integer          not null, primary key\n#  total_incoming :bigint           default(0)\n#  total_messages :bigint           default(0)\n#  total_outgoing :bigint           default(0)\n#\n\nclass Statistic < ApplicationRecord\n\n  def self.global\n    Statistic.first || Statistic.create\n  end\n\nend\n"
  },
  {
    "path": "app/models/track_domain.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: track_domains\n#\n#  id                     :integer          not null, primary key\n#  uuid                   :string(255)\n#  server_id              :integer\n#  domain_id              :integer\n#  name                   :string(255)\n#  dns_checked_at         :datetime\n#  dns_status             :string(255)\n#  dns_error              :string(255)\n#  created_at             :datetime         not null\n#  updated_at             :datetime         not null\n#  ssl_enabled            :boolean          default(TRUE)\n#  track_clicks           :boolean          default(TRUE)\n#  track_loads            :boolean          default(TRUE)\n#  excluded_click_domains :text(65535)\n#\n\nrequire \"resolv\"\n\nclass TrackDomain < ApplicationRecord\n\n  include HasUUID\n\n  belongs_to :server\n  belongs_to :domain\n\n  validates :name, presence: true, format: { with: /\\A[a-z0-9-]+\\z/ }, uniqueness: { scope: :domain_id, case_sensitive: false, message: \"is already added\" }\n  validates :domain_id, uniqueness: { scope: :server_id, case_sensitive: false, message: \"already has a track domain for this server\" }\n  validate :validate_domain_belongs_to_server\n\n  scope :ok, -> { where(dns_status: \"OK\") }\n\n  after_create :check_dns, unless: :dns_status\n\n  before_validation do\n    self.server = domain.server if domain && server.nil?\n  end\n\n  def full_name\n    \"#{name}.#{domain.name}\"\n  end\n\n  def excluded_click_domains_array\n    @excluded_click_domains_array ||= excluded_click_domains ? excluded_click_domains.split(\"\\n\").map(&:strip) : []\n  end\n\n  def dns_ok?\n    dns_status == \"OK\"\n  end\n\n  def check_dns\n    records = domain.resolver.cname(full_name)\n    if records.empty?\n      self.dns_status = \"Missing\"\n      self.dns_error = \"There is no record at #{full_name}\"\n    elsif records.size == 1 && records.first == Postal::Config.dns.track_domain\n      self.dns_status = \"OK\"\n      self.dns_error = nil\n    else\n      self.dns_status = \"Invalid\"\n      self.dns_error = \"There is a CNAME record at #{full_name} but it points to #{records.first} which is incorrect. It should point to #{Postal::Config.dns.track_domain}.\"\n    end\n    self.dns_checked_at = Time.now\n    save!\n    dns_ok?\n  end\n\n  def use_ssl?\n    ssl_enabled?\n  end\n\n  def validate_domain_belongs_to_server\n    return unless domain && ![server, server.organization].include?(domain.owner)\n\n    errors.add :domain, \"does not belong to the server or the server's organization\"\n  end\n\nend\n"
  },
  {
    "path": "app/models/user.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: users\n#\n#  id                               :integer          not null, primary key\n#  admin                            :boolean          default(FALSE)\n#  email_address                    :string(255)\n#  email_verification_token         :string(255)\n#  email_verified_at                :datetime\n#  first_name                       :string(255)\n#  last_name                        :string(255)\n#  oidc_issuer                      :string(255)\n#  oidc_uid                         :string(255)\n#  password_digest                  :string(255)\n#  password_reset_token             :string(255)\n#  password_reset_token_valid_until :datetime\n#  time_zone                        :string(255)\n#  uuid                             :string(255)\n#  created_at                       :datetime\n#  updated_at                       :datetime\n#\n# Indexes\n#\n#  index_users_on_email_address  (email_address)\n#  index_users_on_uuid           (uuid)\n#\n\nclass User < ApplicationRecord\n\n  include HasUUID\n  include HasAuthentication\n\n  validates :first_name, presence: true\n  validates :last_name, presence: true\n  validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: /@/, allow_blank: true }\n\n  default_value :time_zone, -> { \"UTC\" }\n\n  has_many :organization_users, dependent: :destroy, as: :user\n  has_many :organizations, through: :organization_users\n\n  def organizations_scope\n    if admin?\n      @organizations_scope ||= Organization.present\n    else\n      @organizations_scope ||= organizations.present\n    end\n  end\n\n  def name\n    \"#{first_name} #{last_name}\"\n  end\n\n  def password?\n    password_digest.present?\n  end\n\n  def oidc?\n    oidc_uid.present?\n  end\n\n  def to_param\n    uuid\n  end\n\n  def email_tag\n    \"#{name} <#{email_address}>\"\n  end\n\n  class << self\n\n    # Lookup a user by email address\n    #\n    # @param email [String] the email address\n    #\n    # @return [User, nil] the user\n    def [](email)\n      find_by(email_address: email)\n    end\n\n    # Find a user based on an OIDC authentication hash\n    #\n    # @param auth [Hash] the authentication hash\n    # @param logger [Logger] a logger to log debug information to\n    #\n    # @return [User, nil] the user\n    def find_from_oidc(auth, logger: nil)\n      config = Postal::Config.oidc\n\n      uid = auth[config.uid_field]\n      oidc_name = auth[config.name_field]\n      oidc_email_address = auth[config.email_address_field]\n\n      logger&.debug \"got auth details from issuer: #{auth.inspect}\"\n\n      # look for an existing user with the same UID and OIDC issuer. If we find one,\n      # this is the user we'll want to use.\n      user = where(oidc_uid: uid, oidc_issuer: config.issuer).first\n\n      if user\n        logger&.debug \"found user with UID #{uid} for issuer #{config.issuer} (user ID: #{user.id})\"\n      else\n        logger&.debug \"no user with UID #{uid} for issuer #{config.issuer}\"\n      end\n\n      # if we don't have an existing user, we will look for users which have no OIDC\n      # credentials but with a matching e-mail address.\n      if user.nil? && oidc_email_address.present?\n        user = where(oidc_uid: nil, email_address: oidc_email_address).first\n        if user\n          logger&.debug \"found user with e-mail address #{oidc_email_address} (user ID: #{user.id})\"\n        else\n          logger&.debug \"no user with e-mail address #{oidc_email_address}\"\n        end\n      end\n\n      # now, if we still don't have a user, we're not going to create one so we'll just\n      # return nil (we might auto create users in the future but not right now)\n      return if user.nil?\n\n      # otherwise, let's update our user as appropriate\n      user.oidc_uid = uid\n      user.oidc_issuer = config.issuer\n      user.email_address = oidc_email_address if oidc_email_address.present?\n      user.first_name, user.last_name = oidc_name.split(/\\s+/, 2) if oidc_name.present?\n      user.password = nil\n      user.save!\n\n      # return the user\n      user\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/models/user_invite.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: user_invites\n#\n#  id            :integer          not null, primary key\n#  uuid          :string(255)\n#  email_address :string(255)\n#  expires_at    :datetime\n#  created_at    :datetime\n#  updated_at    :datetime\n#\n# Indexes\n#\n#  index_user_invites_on_uuid  (uuid)\n#\n\nclass UserInvite < ApplicationRecord\n\n  include HasUUID\n\n  validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: /@/, allow_blank: true }\n\n  has_many :organization_users, dependent: :destroy, as: :user\n  has_many :organizations, through: :organization_users\n\n  default_value :expires_at, -> { 7.days.from_now }\n\n  scope :active, -> { where(\"expires_at > ?\", Time.now) }\n\n  def md5_for_gravatar\n    @md5_for_gravatar ||= Digest::MD5.hexdigest(email_address.to_s.downcase)\n  end\n\n  def avatar_url\n    @avatar_url ||= email_address ? \"https://secure.gravatar.com/avatar/#{md5_for_gravatar}?rating=PG&size=120&d=mm\" : nil\n  end\n\n  def name\n    email_address\n  end\n\n  def accept(user)\n    transaction do\n      organization_users.each do |ou|\n        ou.update(user: user) || ou.destroy\n      end\n      organization_users.reload\n      destroy\n    end\n  end\n\n  def reject\n    destroy\n  end\n\nend\n"
  },
  {
    "path": "app/models/webhook.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: webhooks\n#\n#  id           :integer          not null, primary key\n#  server_id    :integer\n#  uuid         :string(255)\n#  name         :string(255)\n#  url          :string(255)\n#  last_used_at :datetime\n#  all_events   :boolean          default(FALSE)\n#  enabled      :boolean          default(TRUE)\n#  sign         :boolean          default(TRUE)\n#  created_at   :datetime\n#  updated_at   :datetime\n#\n# Indexes\n#\n#  index_webhooks_on_server_id  (server_id)\n#\n\nclass Webhook < ApplicationRecord\n\n  include HasUUID\n\n  belongs_to :server\n  has_many :webhook_events, dependent: :destroy\n  has_many :webhook_requests\n\n  validates :name, presence: true\n  validates :url, presence: true, format: { with: /\\Ahttps?:\\/\\/[a-z0-9\\-._?=&\\/+:%@]+\\z/i, allow_blank: true }\n\n  scope :enabled, -> { where(enabled: true) }\n\n  after_save :save_events\n  after_save :destroy_events_when_all_events_enabled\n\n  def events\n    @events ||= webhook_events.map(&:event)\n  end\n\n  def events=(value)\n    @events = value.map(&:to_s).select(&:present?)\n  end\n\n  private\n\n  def save_events\n    return unless @events\n\n    @events.each do |event|\n      webhook_events.where(event: event).first_or_create!\n    end\n\n    webhook_events.where.not(event: @events).destroy_all\n  end\n\n  def destroy_events_when_all_events_enabled\n    return unless all_events\n\n    webhook_events.destroy_all\n  end\n\nend\n"
  },
  {
    "path": "app/models/webhook_event.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: webhook_events\n#\n#  id         :integer          not null, primary key\n#  webhook_id :integer\n#  event      :string(255)\n#  created_at :datetime\n#\n# Indexes\n#\n#  index_webhook_events_on_webhook_id  (webhook_id)\n#\n\nclass WebhookEvent < ApplicationRecord\n\n  EVENTS = %w[\n    MessageSent\n    MessageDelayed\n    MessageDeliveryFailed\n    MessageHeld\n    MessageBounced\n    MessageLinkClicked\n    MessageLoaded\n    DomainDNSError\n  ].freeze\n\n  belongs_to :webhook\n\n  validates :event, presence: true\n\nend\n"
  },
  {
    "path": "app/models/webhook_request.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: webhook_requests\n#\n#  id          :integer          not null, primary key\n#  attempts    :integer          default(0)\n#  error       :text(65535)\n#  event       :string(255)\n#  locked_at   :datetime\n#  locked_by   :string(255)\n#  payload     :text(65535)\n#  retry_after :datetime\n#  url         :string(255)\n#  uuid        :string(255)\n#  created_at  :datetime\n#  server_id   :integer\n#  webhook_id  :integer\n#\n# Indexes\n#\n#  index_webhook_requests_on_locked_by  (locked_by)\n#\n\nclass WebhookRequest < ApplicationRecord\n\n  include HasUUID\n  include HasLocking\n\n  belongs_to :server\n  belongs_to :webhook, optional: true\n\n  validates :url, presence: true\n  validates :event, presence: true\n\n  serialize :payload, type: Hash\n\n  class << self\n\n    def trigger(server, event, payload = {})\n      unless server.is_a?(Server)\n        server = Server.find(server.to_i)\n      end\n\n      webhooks = server.webhooks.enabled.includes(:webhook_events).references(:webhook_events).where(\"webhooks.all_events = ? OR webhook_events.event = ?\", true, event)\n      webhooks.each do |webhook|\n        server.webhook_requests.create!(event: event, payload: payload, webhook: webhook, url: webhook.url)\n      end\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/models/worker_role.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: worker_roles\n#\n#  id          :bigint           not null, primary key\n#  acquired_at :datetime\n#  role        :string(255)\n#  worker      :string(255)\n#\n# Indexes\n#\n#  index_worker_roles_on_role  (role) UNIQUE\n#\nclass WorkerRole < ApplicationRecord\n\n  class << self\n\n    # Acquire or renew a lock for the given role.\n    #\n    # @param role [String] The name of the role to acquire\n    # @return [Symbol, false] True if the lock was acquired or renewed, false otherwise\n    def acquire(role)\n      # update our existing lock if we already have one\n      updates = where(role: role, worker: Postal.locker_name).update_all(acquired_at: Time.current)\n      return :renewed if updates.positive?\n\n      # attempt to steal a role from another worker\n      updates = where(role: role).where(\"acquired_at is null OR acquired_at < ?\", 5.minutes.ago)\n                                 .update_all(acquired_at: Time.current, worker: Postal.locker_name)\n      return :stolen if updates.positive?\n\n      # attempt to create a new role for this worker\n      begin\n        create!(role: role, worker: Postal.locker_name, acquired_at: Time.current)\n        :created\n      rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid\n        false\n      end\n    end\n\n    # Release a lock for the given role for the current process.\n    #\n    # @param role [String] The name of the role to release\n    # @return [Boolean] True if the lock was released, false otherwise\n    def release(role)\n      updates = where(role: role, worker: Postal.locker_name).delete_all\n      updates.positive?\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/scheduled_tasks/action_deletions_scheduled_task.rb",
    "content": "# frozen_string_literal: true\n\nclass ActionDeletionsScheduledTask < ApplicationScheduledTask\n\n  def call\n    Organization.deleted.each do |org|\n      logger.info \"permanently removing organization #{org.id} (#{org.permalink})\"\n      org.destroy\n    end\n\n    Server.deleted.each do |server|\n      logger.info \"permanently removing server #{server.id} (#{server.full_permalink})\"\n      server.destroy\n    end\n  end\n\nend\n"
  },
  {
    "path": "app/scheduled_tasks/application_scheduled_task.rb",
    "content": "# frozen_string_literal: true\n\nclass ApplicationScheduledTask\n\n  def initialize(logger:)\n    @logger = logger\n  end\n\n  def call\n    raise NotImplementedError\n  end\n\n  attr_reader :logger\n\n  class << self\n\n    def next_run_after\n      quarter_past_each_hour\n    end\n\n    private\n\n    def quarter_past_each_hour\n      time = Time.current\n      time = time.change(min: 15, sec: 0)\n      time += 1.hour if time < Time.current\n      time\n    end\n\n    def quarter_to_each_hour\n      time = Time.current\n      time = time.change(min: 45, sec: 0)\n      time += 1.hour if time < Time.current\n      time\n    end\n\n    def three_am\n      time = Time.current\n      time = time.change(hour: 3, min: 0, sec: 0)\n      time += 1.day if time < Time.current\n      time\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/scheduled_tasks/check_all_dns_scheduled_task.rb",
    "content": "# frozen_string_literal: true\n\nclass CheckAllDNSScheduledTask < ApplicationScheduledTask\n\n  def call\n    Domain.where.not(dns_checked_at: nil).where(\"dns_checked_at <= ?\", 1.hour.ago).each do |domain|\n      logger.info \"checking DNS for domain: #{domain.name}\"\n      domain.check_dns(:auto)\n    end\n\n    TrackDomain.where(\"dns_checked_at IS NULL OR dns_checked_at <= ?\", 1.hour.ago).includes(:domain).each do |domain|\n      logger.info \"checking DNS for track domain: #{domain.full_name}\"\n      domain.check_dns\n    end\n  end\n\nend\n"
  },
  {
    "path": "app/scheduled_tasks/cleanup_authie_sessions_scheduled_task.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"authie/session\"\n\nclass CleanupAuthieSessionsScheduledTask < ApplicationScheduledTask\n\n  def call\n    Authie::Session.cleanup\n  end\n\nend\n"
  },
  {
    "path": "app/scheduled_tasks/expire_held_messages_scheduled_task.rb",
    "content": "# frozen_string_literal: true\n\nclass ExpireHeldMessagesScheduledTask < ApplicationScheduledTask\n\n  def call\n    Server.all.each do |server|\n      messages = server.message_db.messages(where: {\n        status: \"Held\",\n        hold_expiry: { less_than: Time.now.to_f }\n      })\n\n      messages.each(&:cancel_hold)\n    end\n  end\n\nend\n"
  },
  {
    "path": "app/scheduled_tasks/process_message_retention_scheduled_task.rb",
    "content": "# frozen_string_literal: true\n\nclass ProcessMessageRetentionScheduledTask < ApplicationScheduledTask\n\n  def call\n    Server.all.each do |server|\n      if server.raw_message_retention_days\n        # If the server has a maximum number of retained raw messages, remove any that are older than this\n        logger.info \"Tidying raw messages (by days) for #{server.permalink} (ID: #{server.id}). Keeping #{server.raw_message_retention_days} days.\"\n        server.message_db.provisioner.remove_raw_tables_older_than(server.raw_message_retention_days)\n      end\n\n      if server.raw_message_retention_size\n        logger.info \"Tidying raw messages (by size) for #{server.permalink} (ID: #{server.id}). Keeping #{server.raw_message_retention_size} MB of data.\"\n        server.message_db.provisioner.remove_raw_tables_until_less_than_size(server.raw_message_retention_size * 1024 * 1024)\n      end\n\n      if server.message_retention_days\n        logger.info \"Tidying messages for #{server.permalink} (ID: #{server.id}). Keeping #{server.message_retention_days} days.\"\n        server.message_db.provisioner.remove_messages(server.message_retention_days)\n      end\n    end\n  end\n\n  def self.next_run_after\n    three_am\n  end\n\nend\n"
  },
  {
    "path": "app/scheduled_tasks/prune_suppression_lists_scheduled_task.rb",
    "content": "# frozen_string_literal: true\n\nclass PruneSuppressionListsScheduledTask < ApplicationScheduledTask\n\n  def call\n    Server.all.each do |s|\n      logger.info \"Pruning suppression lists for server #{s.id}\"\n      s.message_db.suppression_list.prune\n    end\n  end\n\n  def self.next_run_after\n    three_am\n  end\n\nend\n"
  },
  {
    "path": "app/scheduled_tasks/prune_webhook_requests_scheduled_task.rb",
    "content": "# frozen_string_literal: true\n\nclass PruneWebhookRequestsScheduledTask < ApplicationScheduledTask\n\n  def call\n    Server.all.each do |s|\n      logger.info \"Pruning webhook requests for server #{s.id}\"\n      s.message_db.webhooks.prune\n    end\n  end\n\n  def self.next_run_after\n    quarter_to_each_hour\n  end\n\nend\n"
  },
  {
    "path": "app/scheduled_tasks/send_notifications_scheduled_task.rb",
    "content": "# frozen_string_literal: true\n\nclass SendNotificationsScheduledTask < ApplicationScheduledTask\n\n  def call\n    Server.send_send_limit_notifications\n  end\n\n  def self.next_run_after\n    1.minute.from_now\n  end\n\nend\n"
  },
  {
    "path": "app/scheduled_tasks/tidy_queued_messages_task.rb",
    "content": "# frozen_string_literal: true\n\nclass TidyQueuedMessagesTask < ApplicationScheduledTask\n\n  def call\n    QueuedMessage.with_stale_lock.in_batches do |messages|\n      messages.each do |message|\n        logger.info \"removing queued message #{message.id} (locked at #{message.locked_at} by #{message.locked_by})\"\n        message.destroy\n      end\n    end\n  end\n\n  def self.next_run_after\n    quarter_to_each_hour\n  end\n\nend\n"
  },
  {
    "path": "app/senders/base_sender.rb",
    "content": "# frozen_string_literal: true\n\nclass BaseSender\n\n  def start\n  end\n\n  def send_message(message)\n  end\n\n  def finish\n  end\n\nend\n"
  },
  {
    "path": "app/senders/http_sender.rb",
    "content": "# frozen_string_literal: true\n\nclass HTTPSender < BaseSender\n\n  def initialize(endpoint, options = {})\n    super()\n    @endpoint = endpoint\n    @options = options\n    @log_id = SecureRandom.alphanumeric(8).upcase\n  end\n\n  def send_message(message)\n    start_time = Time.now\n    result = SendResult.new\n    result.log_id = @log_id\n\n    request_options = {}\n    request_options[:sign] = true\n    request_options[:timeout] = @endpoint.timeout || 5\n    case @endpoint.encoding\n    when \"BodyAsJSON\"\n      request_options[:json] = parameters(message, flat: false).to_json\n    when \"FormData\"\n      request_options[:params] = parameters(message, flat: true)\n    end\n\n    log \"Sending request to #{@endpoint.url}\"\n    response = Postal::HTTP.post(@endpoint.url, request_options)\n    result.secure = !!response[:secure] # rubocop:disable Style/DoubleNegation\n    result.details = \"Received a #{response[:code]} from #{@endpoint.url}\"\n    log \"  -> Received: #{response[:code]}\"\n    if response[:body]\n      log \"  -> Body: #{response[:body][0, 255]}\"\n      result.output = response[:body].to_s[0, 500].strip\n    end\n    if response[:code] >= 200 && response[:code] < 300\n      # This is considered a success\n      result.type = \"Sent\"\n    elsif response[:code] >= 500 && response[:code] < 600\n      # This is temporary. They might fix their server so it should soft fail.\n      result.type = \"SoftFail\"\n      result.retry = true\n    elsif response[:code].negative?\n      # Connection/SSL etc... errors\n      result.type = \"SoftFail\"\n      result.retry = true\n      result.connect_error = true\n    elsif response[:code] == 429\n      # Rate limit exceeded, treat as a hard fail and don't send bounces\n      result.type = \"HardFail\"\n      result.suppress_bounce = true\n    else\n      # This is permanent. Any other error isn't cool with us.\n      result.type = \"HardFail\"\n    end\n    result.time = (Time.now - start_time).to_f.round(2)\n    result\n  end\n\n  private\n\n  def log(text)\n    Postal.logger.info text, id: @log_id, component: \"http-sender\"\n  end\n\n  def parameters(message, options = {})\n    case @endpoint.format\n    when \"Hash\"\n      hash = {\n        id: message.id,\n        rcpt_to: message.rcpt_to,\n        mail_from: message.mail_from,\n        token: message.token,\n        subject: message.subject,\n        message_id: message.message_id,\n        timestamp: message.timestamp.to_f,\n        size: message.size,\n        spam_status: message.spam_status,\n        bounce: message.bounce,\n        received_with_ssl: message.received_with_ssl,\n        to: message.headers[\"to\"]&.last,\n        cc: message.headers[\"cc\"]&.last,\n        from: message.headers[\"from\"]&.last,\n        date: message.headers[\"date\"]&.last,\n        in_reply_to: message.headers[\"in-reply-to\"]&.last,\n        references: message.headers[\"references\"]&.last,\n        html_body: message.html_body,\n        attachment_quantity: message.attachments.size,\n        auto_submitted: message.headers[\"auto-submitted\"]&.last,\n        reply_to: message.headers[\"reply-to\"]\n      }\n\n      if @endpoint.strip_replies\n        hash[:plain_body], hash[:replies_from_plain_body] = ReplySeparator.separate(message.plain_body)\n      else\n        hash[:plain_body] = message.plain_body\n      end\n\n      if @endpoint.include_attachments?\n        if options[:flat]\n          message.attachments.each_with_index do |a, i|\n            hash[\"attachments[#{i}][filename]\"] = a.filename\n            hash[\"attachments[#{i}][content_type]\"] = a.content_type\n            hash[\"attachments[#{i}][size]\"] = a.body.to_s.bytesize.to_s\n            hash[\"attachments[#{i}][data]\"] = Base64.encode64(a.body.to_s)\n          end\n        else\n          hash[:attachments] = message.attachments.map do |a|\n            {\n              filename: a.filename,\n              content_type: a.mime_type,\n              size: a.body.to_s.bytesize,\n              data: Base64.encode64(a.body.to_s)\n            }\n          end\n        end\n      end\n\n      hash\n    when \"RawMessage\"\n      {\n        id: message.id,\n        rcpt_to: message.rcpt_to,\n        mail_from: message.mail_from,\n        message: Base64.encode64(message.raw_message),\n        base64: true,\n        size: message.size.to_i\n      }\n    else\n      {}\n    end\n  end\n\nend\n"
  },
  {
    "path": "app/senders/send_result.rb",
    "content": "# frozen_string_literal: true\n\nclass SendResult\n\n  attr_accessor :type\n  attr_accessor :details\n  attr_accessor :retry\n  attr_accessor :output\n  attr_accessor :secure\n  attr_accessor :connect_error\n  attr_accessor :log_id\n  attr_accessor :time\n  attr_accessor :suppress_bounce\n\n  def initialize\n    @details = \"\"\n    yield self if block_given?\n  end\n\nend\n"
  },
  {
    "path": "app/senders/smtp_sender.rb",
    "content": "# frozen_string_literal: true\n\nclass SMTPSender < BaseSender\n\n  attr_reader :endpoints\n\n  # @param domain [String] the domain to send mesages to\n  # @param source_ip_address [IPAddress] the IP address to send messages from\n  # @param log_id [String] an ID to use when logging requests\n  def initialize(domain, source_ip_address = nil, servers: nil, log_id: nil, rcpt_to: nil)\n    super()\n    @domain = domain\n    @source_ip_address = source_ip_address\n    @rcpt_to = rcpt_to\n\n    # An array of servers to forcefully send the message to\n    @servers = servers\n    # Stores all connection errors which we have seen during this send sesssion.\n    @connection_errors = []\n    # Stores all endpoints that we have attempted to deliver mail to\n    @endpoints = []\n    # Generate a log ID which can be used if none has been provided to trace\n    # this SMTP session.\n    @log_id = log_id || SecureRandom.alphanumeric(8).upcase\n  end\n\n  def start\n    servers = @servers || self.class.smtp_relays || resolve_mx_records_for_domain || []\n\n    servers.each do |server|\n      server.endpoints.each do |endpoint|\n        result = connect_to_endpoint(endpoint)\n        return endpoint if result\n      end\n    end\n\n    false\n  end\n\n  def send_message(message)\n    # If we don't have a current endpoint than we should raise an error.\n    if @current_endpoint.nil?\n      return create_result(\"SoftFail\") do |r|\n        r.retry = true\n        r.details = \"No SMTP servers were available for #{@domain}.\"\n        if @endpoints.empty?\n          r.details += \" No hosts to try.\"\n        else\n          hostnames = @endpoints.map { |e| e.server.hostname }.uniq\n          r.details += \" Tried #{hostnames.to_sentence}.\"\n        end\n        r.output = @connection_errors.join(\", \")\n        r.connect_error = true\n      end\n    end\n\n    mail_from = determine_mail_from_for_message(message)\n    raw_message = message.raw_message\n\n    # Append the Resent-Sender header to the mesage to include the\n    # MAIL FROM if the installation is configured to use that?\n    if Postal::Config.postal.use_resent_sender_header?\n      raw_message = \"Resent-Sender: #{mail_from}\\r\\n\" + raw_message\n    end\n\n    rcpt_to = determine_rcpt_to_for_message(message)\n    logger.info \"Sending message #{message.server.id}::#{message.id} to #{rcpt_to}\"\n    send_message_to_smtp_client(raw_message, mail_from, rcpt_to)\n  end\n\n  def finish\n    @endpoints.each(&:finish_smtp_session)\n  end\n\n  private\n\n  # Take a message and attempt to send it to the SMTP server that we are\n  # currently connected to. If there is a connection error, we will just\n  # reset the client and retry again once.\n  #\n  # @param raw_message [String] the raw message to send\n  # @param mail_from [String] the MAIL FROM address to use\n  # @param rcpt_to [String] the RCPT TO address to use\n  # @param retry_on_connection_error [Boolean] if true, we will retry the connection if there is an error\n  #\n  # @return [SendResult]\n  def send_message_to_smtp_client(raw_message, mail_from, rcpt_to, retry_on_connection_error: true)\n    start_time = Time.now\n    smtp_result = @current_endpoint.send_message(raw_message, mail_from, [rcpt_to])\n    logger.info \"Accepted by #{@current_endpoint} for #{rcpt_to}\"\n    create_result(\"Sent\", start_time) do |r|\n      r.details = \"Message for #{rcpt_to} accepted by #{@current_endpoint}\"\n      r.details += \" (from #{@current_endpoint.smtp_client.source_address})\" if @current_endpoint.smtp_client.source_address\n      r.output = smtp_result.string\n    end\n  rescue Net::SMTPServerBusy, Net::SMTPAuthenticationError, Net::SMTPSyntaxError, Net::SMTPUnknownError, Net::ReadTimeout => e\n    logger.error \"#{e.class}: #{e.message}\"\n    @current_endpoint.reset_smtp_session\n\n    create_result(\"SoftFail\", start_time) do |r|\n      r.details = \"Temporary SMTP delivery error when sending to #{@current_endpoint}\"\n      r.output = e.message\n      if e.message =~ /(\\d+) seconds/\n        r.retry = ::Regexp.last_match(1).to_i + 10\n      elsif e.message =~ /(\\d+) minutes/\n        r.retry = (::Regexp.last_match(1).to_i * 60) + 10\n      else\n        r.retry = true\n      end\n    end\n  rescue Net::SMTPFatalError => e\n    logger.error \"#{e.class}: #{e.message}\"\n    @current_endpoint.reset_smtp_session\n\n    create_result(\"HardFail\", start_time) do |r|\n      r.details = \"Permanent SMTP delivery error when sending to #{@current_endpoint}\"\n      r.output = e.message\n    end\n  rescue StandardError => e\n    logger.error \"#{e.class}: #{e.message}\"\n    @current_endpoint.reset_smtp_session\n\n    if defined?(Sentry)\n      # Sentry.capture_exception(e, extra: { log_id: @log_id, server_id: message.server.id, message_id: message.id })\n    end\n\n    create_result(\"SoftFail\", start_time) do |r|\n      r.type = \"SoftFail\"\n      r.retry = true\n      r.details = \"An error occurred while sending the message to #{@current_endpoint}\"\n      r.output = e.message\n    end\n  end\n\n  # Return the MAIL FROM which should be used for the given message\n  #\n  # @param message [MessageDB::Message]\n  # @return [String]\n  def determine_mail_from_for_message(message)\n    return \"\" if message.bounce\n\n    # If the domain has a valid custom return path configured, return\n    # that.\n    if message.domain.return_path_status == \"OK\"\n      return \"#{message.server.token}@#{message.domain.return_path_domain}\"\n    end\n\n    \"#{message.server.token}@#{Postal::Config.dns.return_path_domain}\"\n  end\n\n  # Return the RCPT TO to use for the given message in this sending session\n  #\n  # @param message [MessageDB::Message]\n  # @return [String]\n  def determine_rcpt_to_for_message(message)\n    return @rcpt_to if @rcpt_to\n\n    message.rcpt_to\n  end\n\n  # Return an array of server hostnames which should receive this message\n  #\n  # @return [Array<String>]\n  def resolve_mx_records_for_domain\n    hostnames = DNSResolver.local.mx(@domain, raise_timeout_errors: true).map(&:last)\n    return [SMTPClient::Server.new(@domain)] if hostnames.empty?\n\n    hostnames.map { |hostname| SMTPClient::Server.new(hostname) }\n  end\n\n  # Attempt to begin an SMTP sesssion for the given endpoint. If successful, this endpoint\n  # becomes the current endpoints for the SMTP sender.\n  #\n  # Returns true if the session was established.\n  # Returns false if the session could not be established.\n  #\n  # @param endpoint [SMTPClient::Endpoint]\n  # @return [Boolean]\n  def connect_to_endpoint(endpoint, allow_ssl: true)\n    if @source_ip_address && @source_ip_address.ipv6.blank? && endpoint.ipv6?\n      # Don't try to use IPv6 if the IP address we're sending from doesn't support it.\n      return false\n    end\n\n    # Add this endpoint to the list of endpoints that we have attempted to connect to\n    @endpoints << endpoint unless @endpoints.include?(endpoint)\n\n    endpoint.start_smtp_session(allow_ssl: allow_ssl, source_ip_address: @source_ip_address)\n    logger.info \"Connected to #{endpoint}\"\n    @current_endpoint = endpoint\n\n    true\n  rescue StandardError => e\n    # Disconnect the SMTP client if we get any errors to avoid leaving\n    # a connection around.\n    endpoint.finish_smtp_session\n\n    # If we get an SSL error, we can retry a connection without\n    # ssl.\n    if e.is_a?(OpenSSL::SSL::SSLError) && endpoint.server.ssl_mode == \"Auto\"\n      logger.error \"SSL error (#{e.message}), retrying without SSL\"\n      return connect_to_endpoint(endpoint, allow_ssl: false)\n    end\n\n    # Otherwise, just log the connection error and return false\n    logger.error \"Cannot connect to #{endpoint} (#{e.class}: #{e.message})\"\n    @connection_errors << e.message unless @connection_errors.include?(e.message)\n\n    false\n  end\n\n  # Create a new result object\n  #\n  # @param type [String] the type of result\n  # @param start_time [Time] the time the operation started\n  # @yieldparam [SendResult] the result object\n  # @yieldreturn [void]\n  #\n  # @return [SendResult]\n  def create_result(type, start_time = nil)\n    result = SendResult.new\n    result.type = type\n    result.log_id = @log_id\n    result.secure = @current_endpoint&.smtp_client&.secure_socket? ? true : false\n    yield result if block_given?\n    if start_time\n      result.time = (Time.now - start_time).to_f.round(2)\n    end\n    result\n  end\n\n  def logger\n    @logger ||= Postal.logger.create_tagged_logger(log_id: @log_id)\n  end\n\n  class << self\n\n    # Return an array of SMTP relays as configured. Returns nil\n    # if no SMTP relays are configured.\n    #\n    def smtp_relays\n      return @smtp_relays if instance_variable_defined?(\"@smtp_relays\")\n\n      relays = Postal::Config.postal.smtp_relays\n      return nil if relays.nil?\n\n      relays = relays.filter_map do |relay|\n        next unless relay.host.present?\n\n        SMTPClient::Server.new(relay.host, port: relay.port, ssl_mode: relay.ssl_mode)\n      end\n\n      @smtp_relays = relays.empty? ? nil : relays\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/services/webhook_delivery_service.rb",
    "content": "# frozen_string_literal: true\n\nclass WebhookDeliveryService\n\n  RETRIES = { 1 => 2.minutes, 2 => 3.minutes, 3 => 6.minutes, 4 => 10.minutes, 5 => 15.minutes }.freeze\n\n  def initialize(webhook_request:)\n    @webhook_request = webhook_request\n  end\n\n  def call\n    logger.tagged(webhook: @webhook_request.webhook_id, webhook_request: @webhook_request.id) do\n      generate_payload\n      send_request\n      record_attempt\n      appreciate_http_result\n      update_webhook_request\n    end\n  end\n\n  def success?\n    @success == true\n  end\n\n  private\n\n  def generate_payload\n    @payload = {\n      event: @webhook_request.event,\n      timestamp: @webhook_request.created_at.to_f,\n      payload: @webhook_request.payload,\n      uuid: @webhook_request.uuid\n    }.to_json\n  end\n\n  def send_request\n    @http_result = Postal::HTTP.post(@webhook_request.url,\n                                     sign: true,\n                                     json: @payload,\n                                     timeout: 5)\n\n    @success = (@http_result[:code] >= 200 && @http_result[:code] < 300)\n  end\n\n  def record_attempt\n    @webhook_request.attempts += 1\n\n    if success?\n      @webhook_request.retry_after = nil\n    else\n      @webhook_request.retry_after = RETRIES[@webhook_request.attempts]&.from_now\n    end\n\n    @attempt = @webhook_request.server.message_db.webhooks.record(\n      event: @webhook_request.event,\n      url: @webhook_request.url,\n      webhook_id: @webhook_request.webhook_id,\n      attempt: @webhook_request.attempts,\n      timestamp: Time.now.to_f,\n      payload: @webhook_request.payload.to_json,\n      uuid: @webhook_request.uuid,\n      status_code: @http_result[:code],\n      body: @http_result[:body],\n      will_retry: @webhook_request.retry_after.present?\n    )\n  end\n\n  def appreciate_http_result\n    if success?\n      logger.info \"Received #{@http_result[:code]} status code. That's OK.\"\n      @webhook_request.destroy!\n      @webhook_request.webhook&.update_column(:last_used_at, Time.current)\n      return\n    end\n\n    logger.error \"Received #{@http_result[:code]} status code. That's not OK.\"\n    @webhook_request.error = \"Couldn't send to URL. Code received was #{@http_result[:code]}\"\n  end\n\n  def update_webhook_request\n    if @webhook_request.retry_after\n      logger.info \"Will retry #{@webhook_request.retry_after} (this was attempt #{@webhook_request.attempts})\"\n      @webhook_request.locked_by = nil\n      @webhook_request.locked_at = nil\n      @webhook_request.save!\n      return\n    end\n\n    logger.info \"Have tried #{@webhook_request.attempts} times. Giving up.\"\n    @webhook_request.destroy!\n  end\n\n  def logger\n    Postal.logger\n  end\n\nend\n"
  },
  {
    "path": "app/util/has_prometheus_metrics.rb",
    "content": "# frozen_string_literal: true\n\nmodule HasPrometheusMetrics\n\n  def register_prometheus_counter(name, **kwargs)\n    counter = Prometheus::Client::Counter.new(name, **kwargs)\n    registry.register(counter)\n  end\n\n  def register_prometheus_histogram(name, **kwargs)\n    histogram = Prometheus::Client::Histogram.new(name, **kwargs)\n    registry.register(histogram)\n  end\n\n  def increment_prometheus_counter(name, labels: {})\n    counter = registry.get(name)\n    return if counter.nil?\n\n    counter.increment(labels: labels)\n  end\n\n  def observe_prometheus_histogram(name, time, labels: {})\n    histogram = registry.get(name)\n    return if histogram.nil?\n\n    histogram.observe(time, labels: labels)\n  end\n\n  private\n\n  def registry\n    Prometheus::Client.registry\n  end\n\nend\n"
  },
  {
    "path": "app/util/health_server.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"socket\"\nrequire \"rackup/handler/webrick\"\nrequire \"prometheus/client/formats/text\"\n\nclass HealthServer\n\n  def initialize(name: \"unnamed-process\")\n    @name = name\n  end\n\n  def call(env)\n    case env[\"PATH_INFO\"]\n    when \"/health\"\n      ok\n    when \"/metrics\"\n      metrics\n    when \"/\"\n      root\n    else\n      not_found\n    end\n  end\n\n  private\n\n  def root\n    [200, { \"Content-Type\" => \"text/plain\" }, [\"#{@name} (pid: #{Process.pid}, host: #{hostname})\"]]\n  end\n\n  def ok\n    [200, { \"Content-Type\" => \"text/plain\" }, [\"OK\"]]\n  end\n\n  def not_found\n    [404, { \"Content-Type\" => \"text/plain\" }, [\"Not Found\"]]\n  end\n\n  def metrics\n    registry = Prometheus::Client.registry\n    body = Prometheus::Client::Formats::Text.marshal(registry)\n    [200, { \"Content-Type\" => \"text/plain\" }, [body]]\n  end\n\n  def hostname\n    Socket.gethostname\n  rescue StandardError\n    \"unknown-hostname\"\n  end\n\n  class << self\n\n    def run(default_port:, default_bind_address:, **options)\n      port = ENV.fetch(\"HEALTH_SERVER_PORT\", default_port)\n      bind_address = ENV.fetch(\"HEALTH_SERVER_BIND_ADDRESS\", default_bind_address)\n\n      Rackup::Handler::WEBrick.run(new(**options),\n                                   Port: port,\n                                   BindAddress: bind_address,\n                                   AccessLog: [],\n                                   Logger: LoggerProxy.new)\n    rescue Errno::EADDRINUSE\n      Postal.logger.info \"health server port (#{bind_address}:#{port}) is already \" \\\n                         \"in use, not starting health server\"\n    end\n\n    def start(**options)\n      thread = Thread.new { run(**options) }\n      thread.abort_on_exception = false\n      thread\n    end\n\n  end\n\n  class LoggerProxy\n\n    [:info, :debug, :warn, :error, :fatal].each do |severity|\n      define_method(severity) do |message|\n        add(severity, message)\n      end\n\n      define_method(\"#{severity}?\") do\n        severity != :debug\n      end\n    end\n\n    def add(severity, message)\n      return if severity == :debug\n\n      case message\n      when /\\AWEBrick::HTTPServer#start:.*port=(\\d+)/\n        Postal.logger.info \"started health server on port #{::Regexp.last_match(1)}\", component: \"health-server\"\n      when /\\AWEBrick::HTTPServer#start done/\n        Postal.logger.info \"stopped health server\", component: \"health-server\"\n      when /\\AWEBrick [\\d.]+/,\n           /\\Aruby ([\\d.]+)/,\n           /\\ARackup::Handler::WEBrick is mounted/,\n           /\\Aclose TCPSocket/,\n           /\\Agoing to shutdown/\n        # Don't actually print routine messages to avoid too much\n        # clutter when processes start it\n      else\n        Postal.logger.debug message, component: \"health-server\"\n      end\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/util/user_creator.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"highline\"\n\nmodule UserCreator\n\n  class << self\n\n    def start(&block)\n      cli = HighLine.new\n      puts \"\\e[32mPostal User Creator\\e[0m\"\n      puts \"Enter the information required to create a new Postal user.\"\n      puts \"This tool is usually only used to create your initial admin user.\"\n      puts\n      user = User.new\n      user.email_address = cli.ask(\"E-Mail Address\".ljust(20, \" \") + \": \")\n      user.first_name = cli.ask(\"First Name\".ljust(20, \" \") + \": \")\n      user.last_name = cli.ask(\"Last Name\".ljust(20, \" \") + \": \")\n      user.password = cli.ask(\"Initial Password\".ljust(20, \" \") + \": \") { |value| value.echo = \"*\" }\n\n      block.call(user) if block_given?\n      puts\n      if user.save\n        puts \"User has been created with e-mail address \\e[32m#{user.email_address}\\e[0m\"\n      else\n        puts \"\\e[31mFailed to create user\\e[0m\"\n        user.errors.full_messages.each do |error|\n          puts \" * #{error}\"\n        end\n      end\n      puts\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "app/views/address_endpoints/_form.html.haml",
    "content": "= form_for [organization, @server, @address_endpoint], :remote => true do |f|\n  = f.error_messages\n  %fieldset.fieldSet\n    .fieldSet__field\n      = f.label :address, :class => 'fieldSet__label'\n      .fieldSet__input= f.text_field :address, :autofocus => true, :class => 'input input--text'\n\n  .fieldSetSubmit.buttonSet\n    = f.submit @address_endpoint.new_record? ? \"Create address endpoint\" : \"Save address endpoint\", :class => 'button button--positive js-form-submit'\n    .fieldSetSubmit__delete\n      - if f.object.persisted?\n        = link_to \"Delete address endpoint\", [organization, @server, @address_endpoint], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => \"Are you sure you wish to delete this HTTP endpoint?\\n\\r#{pluralize @address_endpoint.routes.size, 'route'} that uses this endpoint will also be deleted.\"}\n\n  = hidden_field_tag 'return_to', params[:return_to]\n  = hidden_field_tag 'return_notice', params[:return_notice]\n"
  },
  {
    "path": "app/views/address_endpoints/edit.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Routing\"\n- page_title << \"Address Endpoints\"\n- page_title << \"Edit\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :routing\n= render 'routes/header', :active_nav => :address_endpoints\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/address_endpoints/index.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Routing\"\n- page_title << \"Address Endpoints\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :routing\n= render 'routes/header', :active_nav => :address_endpoints\n.pageContent.pageContent--compact\n\n  - if @address_endpoints.empty?\n    .noData.noData--clean\n      %h2.noData__title There aren't any address endpoints yet.\n      %p.noData__text\n        Address endpoints are e-mail addresses hosted on other platforms that you'd\n        like to deliver e-mails to. Once you've created these, you can send messages\n        to them by creating #{link_to 'routes', [organization, @server, :routes], :class => 'u-link'}.\n      %p.noData__button\n        = link_to \"Add your first address endpoint\", [:new, organization, @server, :address_endpoint], :class => 'button button--positive'\n\n  - else\n\n    %ul.endpointList.u-margin\n      - for endpoint in @address_endpoints\n        %li.endpointList__item\n          = link_to [:edit, organization, @server, endpoint], :class => 'endpointList__link' do\n            .endpointList__main\n              %p.endpointList__name= endpoint.address\n            %ul.endpointList__details\n              %li.endpointList__detailItem\n                - if endpoint.last_used_at\n                  Last used #{distance_of_time_in_words_to_now endpoint.last_used_at} ago\n                - else\n                  Not used yet\n\n    %p.u-center= link_to \"Add another address endpoint\", [:new, organization, @server, :address_endpoint], :class => 'button button--positive'\n"
  },
  {
    "path": "app/views/address_endpoints/new.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Routing\"\n- page_title << \"Address Endpoints\"\n- page_title << \"New\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :routing\n= render 'routes/header', :active_nav => :address_endpoints\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/app_mailer/password_reset.text.erb",
    "content": "Hello there,\n\nYou (or someone pretending to be you) have requested a new password for your Postal account. To choose a new password, please click the link below and you'll be able to create a new password and login to Postal.\n\n<%= Postal.host_with_protocol %>/login/reset/<%= @user.password_reset_token %><%= @return_to ? \"?return_to=#{ERB::Util.url_encode(@return_to)}\" : '' %>\n\nIf you didn't request this, you can ignore this e-mail.\n\nThanks,\n\n<%= Postal::Config.smtp.from_name %>\n<%= Postal::Config.smtp.from_address %>\n"
  },
  {
    "path": "app/views/app_mailer/server_send_limit_approaching.text.erb",
    "content": "We're writing to let you know that your <%= @server.name %> mail server is approaching its send limit. All mail servers have a limit of how much e-mail they are permitted to send in a rolling 60 minute window. At present you have sent <%= @server.send_volume %> messages and have a limit of <%= @server.send_limit %>.\n\nOrgaization: <%= @server.organization.name %>\nServer: <%= @server.name %>\nSend Limit: <%= @server.send_limit %>\nCurrent Volume: <%= @server.send_volume %>\n\nWhen you reach your limit, any mail you send will be held in the system until it is manually unheld by you through the web interface or using the API.\n\nYou can view more information about this server at:\n\n<%= Postal.host_with_protocol %>/org/<%= @server.organization.permalink %>/servers/<%= @server.permalink %>\n\nThanks,\n\n<%= Postal::Config.smtp.from_name %>\n<%= Postal::Config.smtp.from_address %>\n"
  },
  {
    "path": "app/views/app_mailer/server_send_limit_exceeded.text.erb",
    "content": "We're writing to let you know that your <%= @server.name %> mail server has exceeded its send limit. All mail servers have a limit of how much e-mail they are permitted to send in a rolling 60 minute window. At present you have sent <%= @server.send_volume %> messages and have a limit of <%= @server.send_limit %>.\n\nOrgaization: <%= @server.organization.name %>\nServer: <%= @server.name %>\nSend Limit: <%= @server.send_limit %>\nCurrent Volume: <%= @server.send_volume %>\n\nAll messages that you send until your volume drops will now be held in the system. You will need to manually release any of these messages that you wish to send. You can do this through the web interface or using the API.\n\nYou can view more information about this server at:\n\n<%= Postal.host_with_protocol %>/org/<%= @server.organization.permalink %>/servers/<%= @server.permalink %>\n\nThanks,\n\n<%= Postal::Config.smtp.from_name %>\n<%= Postal::Config.smtp.from_address %>\n"
  },
  {
    "path": "app/views/app_mailer/server_suspended.text.erb",
    "content": "Hello,\n\nWe're writing to inform you that, unfortunately, we have had to suspend one of your mail servers on Postal.\n\nOrganization: <%= @server.organization.name %>\nServer: <%= @server.name %>\nReason: <%= @server.actual_suspension_reason %>\n\nThanks,\n\n<%= Postal::Config.smtp.from_name %>\n<%= Postal::Config.smtp.from_address %>\n"
  },
  {
    "path": "app/views/app_mailer/test_message.text.erb",
    "content": "This is a test message sent by Postal.\n\nIf you have received this message your test has succeeded.\n"
  },
  {
    "path": "app/views/app_mailer/verify_domain.text.erb",
    "content": "Hello there,\n\n<%= @user.name %> (<%= @domain.owner.is_a?(Organization) ? @domain.owner.name : @domain.owner.organization.name %>) would like to start sending e-mail from <%= @domain.name %> using Postal. We're writing to you to request your authorization to allow this domain to be used to send e-mail through their mail server.\n\nIf you agree, please provide the code below to <%= @user.first_name %> who will be able to enter it into our web interface to continue.\n\n<%= @domain.verification_token %>\n\nIf you don't agree, just ignore this e-mail.\n\nThanks,\n\n<%= Postal::Config.smtp.from_name %>\n<%= Postal::Config.smtp.from_address %>\n"
  },
  {
    "path": "app/views/credentials/_form.html.haml",
    "content": "= form_for [organization, @server, @credential], :remote => true do |f|\n  = f.error_messages\n  %fieldset.fieldSet\n    .fieldSet__field\n      = f.label :type, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :type, Credential::TYPES, {}, :disabled => @credential.persisted?, :class => 'input input--select', :autofocus => @credential.new_record?\n        %p.fieldSet__text\n          This is the service that is associated with this credential. You'll be able to use this key to\n          authenticate to this type of service only.\n    .fieldSet__field\n      = f.label :name, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.text_field :name, :autofocus => @credential.persisted?, :class => 'input input--text'\n        %p.fieldSet__text\n          This is a friendly name so you can identify this credential later. You can enter anything\n          you want here, the more descriptive the better.\n\n    .fieldSet__field{data: {credential_key_type: 'all'}}\n      = f.label :key, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.text_field :key, :readonly => false, :class => 'input input--text input--code', :placeholder => \"Automatically generated\", :tabindex => 1000, :value => (@credential.new_record? ? '' : @credential.key)\n        %p.fieldSet__text\n          This is the unique key which will be used to authenticate any requests to the API or the SMTP servers.\n          It will be generated randomly and cannot be changed. If you need a new token, you can create a new one and then\n          delete the old one when you're ready.\n\n    .fieldSet__field{data: {credential_key_type: 'smtp-ip'}}\n      = f.label :key, \"Network\", :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.text_field :key, :class => 'input input--text input--code'\n        %p.fieldSet__text\n          This is the IP address or network that you wish to allow to authenticate to this mail server.\n\n    .fieldSet__field\n      = f.label :hold, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :hold, [[\"Process all messages\", false], [\"Hold messages from this credential\", true]], {}, :class => 'input input--select'\n        %p.fieldSet__text\n          You may wish to automatically hold all messages that are sent by this credential. This allows you to preview them\n          before they are delivered to their recipients. This is useful for credentials for development environments.\n\n  .fieldSetSubmit.buttonSet\n    = f.submit @credential.new_record? ? \"Create credential\" : \"Save credential\", :class => 'button button--positive js-form-submit'\n    .fieldSetSubmit__delete\n      - if f.object.persisted?\n        = link_to \"Delete credential\", [organization, @server, @credential], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => \"Are you sure you wish to delete this credential?\"}\n\n"
  },
  {
    "path": "app/views/credentials/edit.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Credentials\"\n- page_title << \"Edit\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :credentials\n.pageContent.pageContent--compact\n  = render 'form'\n"
  },
  {
    "path": "app/views/credentials/index.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Credentials\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :credentials\n.pageContent.pageContent--compact\n  - if @credentials.empty?\n    .noData.noData--clean\n      %h2.noData__title There are no credentials for this server.\n      %p.noData__text\n        In order to authenticate to your mail server, you use credentials. Once\n        you've added a credential, you'll have a unique token which you can use to\n        authenticate against our SMTP service or our HTTP API.\n      .noData__button= link_to \"Add your first credential\", [:new, organization, @server, :credential], :class => 'button button--positive'\n  - else\n    %p.pageContent__intro.u-margin\n      In order to authenticate to your mail server, you use credentials. Once\n      you've added a credential, you'll have a unique token which you can use to\n      authenticate against our SMTP service or our HTTP API.\n    %p.u-margin.pageContent__helpLink= link_to \"Read more about sending outgoing e-mails\", [organization, @server, :help_outgoing]\n    %ul.credentialList.u-margin\n      - for credential in @credentials\n        %li.credentialList__item\n          = link_to [:edit, organization, @server, credential], :class => 'credentialList__link' do\n            .credentialList__type\n              %span.label{:class => \"label--credentialType-#{credential.type.underscore}\"}= credential.type.split('-').last\n            .credentialList__properties\n              %p.credentialList__name\n                = credential.name\n                - if credential.hold?\n                  %span.label.label--red Holding\n              %p.credentialList__key= credential.key\n            .credentialList__usedAt{:class => \"credentialList__usedAt--#{credential.usage_type.underscore}\"}\n              - if credential.last_used_at\n                %p.credentialList__usedAtTitle= credential.usage_type\n                %p Used #{distance_of_time_in_words_to_now credential.last_used_at} ago\n              - else\n                %p Not been used yet\n\n    %p.u-center.buttonSet.buttonSet--center\n      = link_to \"Add another credential\", [:new, organization, @server, :credential], :class => 'button button--positive'\n"
  },
  {
    "path": "app/views/credentials/new.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Credentials\"\n- page_title << \"Add Credential\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :credentials\n.pageContent.pageContent--compact\n  = render 'form'\n"
  },
  {
    "path": "app/views/domains/_nav.html.haml",
    "content": ".navBar.navBar--secondary\n  %ul\n    %li.navBar__item= link_to \"Domains\", organization_server_domains_path(organization, @server), :class => ['navBar__link', active_nav == :domains ? 'is-active' : '']\n    %li.navBar__item= link_to \"Tracking Domains\", organization_server_track_domains_path(organization, @server), :class => ['navBar__link', active_nav == :track_domains ? 'is-active' : '']\n"
  },
  {
    "path": "app/views/domains/_verify_with_dns.html.haml",
    "content": "%p.pageContent__intro.u-margin\n  To verify your ownership of <b>#{@domain.name}</b>, you need to add a TXT record to this domain.\n  The TXT record should point to the domain and include the value shown below.\n\n%pre.codeBlock.u-margin= @domain.dns_verification_string\n\n%p.pageContent__intro.u-margin\n  Once you've added this, click the button below to verify the presence of this record and\n  verify your domain.\n\n.buttonSet\n  = link_to \"Verify TXT record\", [:verify, organization, @server, @domain], :remote => true, :method => :post, :class => \"button\"\n  = link_to \"Back to domain list\", [organization, @server, :domains], :class => \"button button--neutral\"\n"
  },
  {
    "path": "app/views/domains/_verify_with_email.html.haml",
    "content": "- if params[:email_address]\n  %p.pageContent__intro.u-margin\n    We've sent an email to <b>#{params[:email_address]}</b>. Please check your e-mail and enter\n    the code you've been sent in the box below.\n  = form_tag request.fullpath, :remote => true do\n    = hidden_field_tag 'email_address', params[:email_address]\n    %p.u-margin\n      = text_field_tag \"code\", params[:code], :autofocus => true, :class => 'input input--text js-multibox'\n    .buttonSet\n      = submit_tag \"Verify this domain\", :class => 'button js-form-submit'\n      = link_to \"Back to domain list\", [organization, @server, :domains], :class => \"button button--neutral\"\n\n\n- else\n  %p.pageContent__intro.u-margin\n    To verify your ownership of <b>#{@domain.name}</b> by e-mail, choose an e-mail address from the list\n    below. We'll then send you an email with a code which you'll need to enter below.\n\n  = form_tag request.fullpath, :remote => true do\n    %p.u-margin\n      = select_tag \"email_address\", options_for_select(@domain.verification_email_addresses), :class => 'input input--select', :autofocus => true\n    %p.buttonSet\n      = submit_tag \"Continue\", :class => 'button'\n      = link_to \"Back to domain list\", [organization, @server, :domains], :class => \"button button--neutral\"\n\n"
  },
  {
    "path": "app/views/domains/index.html.haml",
    "content": "- if @server\n  - page_title << @server.name\n- page_title << \"Domains\"\n\n- if @server\n  = render 'servers/sidebar', :active_server => @server\n  = render 'servers/header', :active_nav => :domains\n  = render 'nav', :active_nav => :domains\n- else\n  .pageHeader\n    %h1.pageHeader__title\n      %span.pageHeader__titlePrevious\n        = @organization.name\n        &rarr;\n      Domains\n  = render 'organizations/nav', :active_nav => :domains\n\n.pageContent.pageContent--compact\n\n  - if @domains.empty?\n    .noData.noData--clean\n      %h2.noData__title There are no domains for this server.\n      %p.noData__text\n        To send & receive messages you need to add & verify the domain you wish to send/receive\n        messages to/from. Add your domain below to get started.\n      %p.noData__button= link_to \"Add your first domain\", [:new, organization, @server, :domain], :class => \"button button--positive\"\n\n  - else\n    %ul.domainList.u-margin\n      - for domain in @domains\n        %li.domainList__item\n          .domainList__details\n            %p.domainList__name\n              = link_to domain.name, [:setup, organization, @server, domain]\n              - if domain.use_for_any?\n                %span.label.label--blue Any\n            %ul.domainList__checks\n              - if domain.spf_status == 'OK'\n                %li.domainList__check.domainList__check--ok SPF\n              - elsif domain.spf_status.nil?\n              - else\n                %li.domainList__check.domainList__check--warning{:title => domain.spf_error}= link_to \"SPF\", [:setup, organization, @server, domain]\n\n              - if domain.dkim_status == 'OK'\n                %li.domainList__check.domainList__check--ok DKIM\n              - elsif domain.dkim_status.nil?\n              - else\n                %li.domainList__check.domainList__check--warning{:title => domain.dkim_error}= link_to \"DKIM\", [:setup, organization, @server, domain]\n\n              - if domain.mx_status == 'OK'\n                %li.domainList__check.domainList__check--ok MX\n              - elsif domain.mx_status.nil?\n              - else\n                %li.domainList__check.domainList__check--neutral-cross{:title => domain.mx_error}= link_to \"MX\", [:setup, organization, @server, domain]\n\n              - if domain.return_path_status == 'OK'\n                %li.domainList__check.domainList__check--ok Return Path\n              - elsif domain.return_path_status.nil?\n              - elsif domain.return_path_status == 'Missing'\n                %li.domainList__check.domainList__check--neutral{:title => domain.return_path_error}= link_to \"Return Path\", [:setup, organization, @server, domain]\n              - else\n                %li.domainList__check.domainList__check--warning{:title => domain.return_path_error}= link_to \"Return Path\", [:setup, organization, @server, domain]\n\n          %ul.domainList__properties\n            - if domain.verified?\n              %li.domainList__verificationTime Verified on #{domain.verified_at.to_fs(:long)}\n            - else\n              %li= link_to \"Verify this domain\", [:verify, organization, @server, domain], :class => \"domainList__verificationLink\"\n            %li.domainList__links\n              - if domain.verified?\n                = link_to \"DNS setup\", [:setup, organization, @server, domain]\n              = link_to \"Delete\", [organization, @server, domain], :remote => :delete, :method => :delete, :data => {:confirm => \"Are you sure you wish to remove this domain?\", :disable_with => \"Deleting...\"}, :class => 'domainList__delete'\n\n    %p.u-center= link_to \"Add new domain\", [:new, organization, @server, :domain], :class => \"button button--positive\"\n"
  },
  {
    "path": "app/views/domains/new.html.haml",
    "content": "- if @server\n  - page_title << @server.name\n- page_title << \"Add Domain\"\n\n- if @server\n  = render 'servers/sidebar', :active_server => @server\n  = render 'servers/header', :active_nav => :domains\n  = render 'nav', :active_nav => :domains\n- else\n  .pageHeader\n    %h1.pageHeader__title\n      %span.pageHeader__titlePrevious\n        = @organization.name\n        &rarr; Domains &rarr;\n      Add new domain\n  = render 'organizations/nav', :active_nav => :domains\n\n.pageContent.pageContent--compact\n  = form_for [organization, @server, @domain], :remote => true do |f|\n    = f.error_messages\n    %fieldset.fieldSet\n      .fieldSet__field\n        = f.label :name, :class => 'fieldSet__label'\n        .fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'\n      - unless current_user.admin?\n        .fieldSet__field\n          = f.label :verification_method, :class => 'fieldSet__label'\n          .fieldSet__input\n            = f.select :verification_method, Domain::VERIFICATION_METHODS, {}, :class => 'input input--select'\n            .fieldSet__text\n              Choose how you'd like to verify your ownership of this domain. If you choose <b>E-Mail</b> we can send you\n              an email with a code whcih you'll need to enter - you can choose from a set of pre-defined addresses for\n              the domain. Using <b>DNS</b> you'll need to add a TXT record on this domain using your DNS provider.\n\n    .fieldSetSubmit\n      = f.submit :class => \"button button--positive js-form-submit\"\n"
  },
  {
    "path": "app/views/domains/setup.html.haml",
    "content": "- if @server\n  - page_title << @server.name\n- page_title << @domain.name\n- page_title << \"DNS Setup\"\n\n\n- if @server\n  = render 'servers/sidebar', :active_server => @server\n  = render 'servers/header', :active_nav => :domains\n  = render 'nav', :active_nav => :domains\n- else\n  .pageHeader\n    %h1.pageHeader__title\n      %span.pageHeader__titlePrevious\n        = @organization.name\n        &rarr; Domains &rarr;\n      = @domain.name\n  = render 'organizations/nav', :active_nav => :domains\n\n.pageContent.pageContent--compact\n  %h2.pageContent__title DNS Setup for #{@domain.name}\n  %p.pageContent__intro.u-margin\n    Follow the instructions below to configure SPF & DKIM records for this domain.\n    We highly recommend that you do this to ensure your messages are delivered\n    correctly and quickly.\n\n  .u-margin.buttonSet\n    = link_to \"Check my records are correct\", [:check, organization, @server, @domain], :remote => true, :method => :post, :class => 'button'\n    = link_to \"Back to domain list\", [organization, @server, :domains], :class => 'button button--neutral'\n  - if @domain.dns_checked_at\n    %p.u-margin We last checked the validity of your DNS records #{distance_of_time_in_words_to_now @domain.dns_checked_at} ago.\n\n  %h3.pageContent__subTitle SPF Record\n  - if @domain.spf_status == 'OK'\n    %p.pageContent__text.u-green.u-bold\n      %span.label.label--green Good\n      Your SPF record looks good!\n  - elsif !@domain.spf_status.nil?\n    %p.pageContent__text.u-orange.u-bold\n      %span.label.label--orange Warning\n      = @domain.spf_error\n\n  %p.pageContent__text\n    You need to add a TXT record at the apex/root of your domain (@) with the following\n    content. If you already send mail from another service, you may just need to add\n    <b>include:#{Postal::Config.dns.spf_include}</b> to your existing record.\n  %pre.codeBlock.u-margin= @domain.spf_record\n\n  %h3.pageContent__subTitle DKIM Record\n  - if @domain.dkim_status == 'OK'\n    %p.pageContent__text.u-green.u-bold\n      %span.label.label--green Good\n      Your DKIM record looks good!\n  - elsif !@domain.dkim_status.nil?\n    %p.pageContent__text.u-orange.u-bold\n      %span.label.label--orange Warning\n      = @domain.dkim_error\n\n  %p.pageContent__text\n    You need to add a new TXT record with the name <b>#{@domain.dkim_record_name}</b>\n    with the following content.\n  %pre.codeBlock.u-margin= @domain.dkim_record\n\n  %h3.pageContent__subTitle Return Path\n  - if @domain.return_path_status == 'OK'\n    %p.pageContent__text.u-green.u-bold\n      %span.label.label--green Good\n      Your return path looks good. We'll use this when sending e-mail from this domain.\n  - elsif @domain.return_path_status == 'Missing'\n    %p.pageContent__text.u-grey.u-bold\n      %span.label.label--grey OK\n      There's no return path for this domain. This is OK but we recommend adding the record to improve deliverability and achieve DMARC alignment.\n  - elsif !@domain.return_path_status.nil?\n    %p.pageContent__text.u-orange.u-bold\n      %span.label.label--orange Warning\n      = @domain.return_path_error\n\n  %p.pageContent__text\n    This is optional but we recommend adding this to improve deliverability. You should add\n    a <b>CNAME</b> record at <b>#{@domain.return_path_domain}</b> to point to the hostname below.\n  %pre.codeBlock.u-margin= Postal::Config.dns.return_path_domain\n\n\n  %h3.pageContent__subTitle MX Records\n  - if @domain.mx_status == 'OK'\n    %p.pageContent__text.u-green.u-bold\n      %span.label.label--green Good\n      Your MX records look like they're good to go!\n  - elsif @domain.mx_status == 'Missing'\n    %p.pageContent__text.u-grey.u-bold\n      %span.label.label--grey OK\n      None of the MX records for this domain point to us. Incoming mail won't be sent to us.\n  - elsif !@domain.mx_status.nil?\n    %p.pageContent__text.u-orange.u-bold\n      %span.label.label--orange Warning\n      = @domain.mx_error\n\n  %p.pageContent__text\n    If you wish to receive incoming e-mail for this domain, you need to add the following MX records\n    to the domain. You don't have to do this and we'll only tell you if they're set up or not. Both\n    records should be priority <b>10</b>.\n  %pre.codeBlock.u-margin= Postal::Config.dns.mx_records.join(\"\\n\")\n"
  },
  {
    "path": "app/views/domains/verify.html.haml",
    "content": "- if @server\n  - page_title << @server.name\n- page_title << @domain.name\n- page_title << \"Verify\"\n\n\n- if @server\n  = render 'servers/sidebar', :active_server => @server\n  = render 'servers/header', :active_nav => :domains\n  = render 'nav', :active_nav => :domains\n- else\n  .pageHeader\n    %h1.pageHeader__title\n      %span.pageHeader__titlePrevious\n        = @organization.name\n        &rarr; Domains &rarr;\n      = @domain.name\n  = render 'organizations/nav', :active_nav => :domains\n\n.pageContent.pageContent--compact\n  = render :partial => \"verify_with_#{@domain.verification_method.underscore}\"\n"
  },
  {
    "path": "app/views/help/_header.html.haml",
    "content": ".navBar.navBar--secondary\n  %ul\n    %li.navBar__item= link_to \"Sending E-Mail\", [organization, @server, :help_outgoing], :class => ['navBar__link', active_nav == :outgoing ? 'is-active' : '']\n    %li.navBar__item= link_to \"Receiving E-Mail\", [organization, @server, :help_incoming], :class => ['navBar__link', active_nav == :incoming ? 'is-active' : '']\n\n"
  },
  {
    "path": "app/views/help/incoming.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Help\"\n- page_title << \"Receiving E-Mail\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :help\n= render 'header', :active_nav => :incoming\n\n.pageContent.pageContent--compact\n  %h1.pageContent__title Receiving e-mail\n  %h2.pageContent__intro.u-margin\n    This system can handle your incoming e-mail by accepting it from other mail servers and\n    sending it on to your own applications using HTTP or to forward it to other SMTP servers.\n  %p.u-margin.pageContent__helpLink= link_to \"Read more about sending e-mails\", [organization, @server, :help_outgoing]\n  .u-margin\n    %h2.pageContent__subTitle Forwarding e-mails\n    %p.pageContent__text\n      If you already have a incoming mail server for your domain, you may find the quickest\n      way to get up and running is to simply forward e-mail from that server.\n      You don't need to make any changes to your DNS to do this.\n    %p.pageContent__text\n      Just #{link_to \"create an incoming route\", [organization, @server, :routes], :class => \"u-link\"}\n      for the address you want to receive messages for and then you'll be provided with\n      an e-mail address that messages can be forward to. Any message that is received to\n      this address will be treated as if it had been sent directly to the address on the route.\n    %p.pageContent__text\n      The address to forward mail to can be found by clicking on the route and copying the\n      field marked Address from the form.\n\n  .u-margin\n    %h2.pageContent__subTitle Setting your MX records\n    %p.pageContent__text\n      If you don't already have a mail server on your domain, you can simply set your\n      MX records to point to this system. The MX records are shown\n      below and you should add these both as priority 10 in your DNS configuration. Once\n      these have been added successfully they will show with a green tick on your domain list.\n    %dl.pageContent__definitions\n      %dt MX Records\n      %dd\n        - for mx in Postal::Config.dns.mx_records\n          %p.pageContent__definitionCode= mx\n"
  },
  {
    "path": "app/views/help/outgoing.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Help\"\n- page_title << \"Sending E-Mail\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :help\n= render 'header', :active_nav => :outgoing\n.pageContent.pageContent--compact\n  %h1.pageContent__title Sending e-mail\n  %h2.pageContent__intro.u-margin\n    There are a couple of different ways you send outgoing mail through a\n    mail server. These methods are shown below:\n  %p.u-margin.pageContent__helpLink= link_to \"Read more about receiving e-mails\", [organization, @server, :help_incoming]\n  .u-margin\n    %h2.pageContent__subTitle Important notes\n    %ul.pageContent__list\n      %li\n        E-mails can only be sent from addresses with domains that you have added to mail server or the server's organization.\n        Mail servers can be enabled to send mail from any domain by the administrator.\n      %li\n        If a message cannot be delivered, the system will not send you a bounce message but dispatch a webhook (if you set one up).\n        If a message delivery fails but can be retried, the system will try #{Postal::Config.postal.default_maximum_delivery_attempts} times to deliver it before giving up.\n  .u-margin\n    %h2.pageContent__subTitle Sending using SMTP\n    %p.pageContent__text\n      These instructions explain how to send messages using the SMTP server.\n\n    %dl.pageContent__definitions\n      %dt SMTP Server Address\n      %dd\n        %p.pageContent__definitionCode= Postal::Config.postal.smtp_hostname\n      %dt Port\n      %dd\n        %p.pageContent__definitionCode= Postal::Config.smtp_server.default_port\n        %p.pageContent__definitionText\n          The SMTP service supports STARTTLS if you wish to send messages securely. Be aware that security\n          cannot guaranteed all the way to their final destination.\n\n      %dt Username\n      %dd\n        %p.pageContent__definitionCode= @server.full_permalink\n      %dt Password\n      %dd\n        - if @credentials['SMTP'].present?\n          %p.pageContent__definitionCode\n            = @credentials['SMTP'].first.key\n          %p.pageContent__definitionText= link_to \"Create more credentials\", [organization, @server, :credentials], :class => \"u-link\"\n        - else\n          %p.warningBox\n            %b No SMTP credentials created for this server yet.\n            A password can be generated from the #{link_to 'credentials', [:new, organization, @server, :credential], :class => \"u-link\"}\n            page. Just create a credential with the <b>SMTP</b> type and add a name which suits the place you'll be using the credentials.\n\n      %dt Authentication Methods\n      %dd\n        %p.pageContent__definitionCode PLAIN, LOGIN or CRAM-MD5\n\n  .u-margin\n    %h2.pageContent__subTitle Sending over HTTP using our API\n    %p.pageContent__text\n      For full information about how to use our HTTP API, please #{link_to 'see the documentation', 'https://docs.postalserver.io/developer/api', :class => \"u-link\"}.\n"
  },
  {
    "path": "app/views/http_endpoints/_form.html.haml",
    "content": "= form_for [organization, @server, @http_endpoint], :remote => true do |f|\n  = f.error_messages\n  %fieldset.fieldSet\n    .fieldSet__field\n      = f.label :name, :class => 'fieldSet__label'\n      .fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'\n    .fieldSet__field\n      = f.label :url, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.text_field :url, :class => 'input input--text'\n        %p.fieldSet__text\n          Enter the full URL that we should POST your messages to. We recommend using https URLs here to\n          ensure your data remains secure in transit.\n    .fieldSet__field\n      = f.label :encoding, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :encoding, HTTPEndpoint::ENCODINGS.map { |e| [t(\"http_endpoint_encodings.#{e.underscore}\"), e] }, {}, :class => 'input input--select'\n        %p.fieldSet__text\n          You can choose how the data will be delivered to your server. We recommend receiving data as JSON which will be\n          posted to your endpoint with an application/json content type. If you choose to use form data, you'll be able\n          to read parameters as normal without parsing any JSON.\n\n    .fieldSet__field\n      = f.label :format, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :format, HTTPEndpoint::FORMATS.map { |e| [t(\"http_endpoint_formats.#{e.underscore}\"), e] }, {}, :class => 'input input--select'\n        %p.fieldSet__text\n          You can choose whether to receive the full raw message or whether you'd prefer to receive a individual properties\n          for a message individually.\n    .fieldSet__field\n      = f.label :strip_replies, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :strip_replies, [[\"Send the full message as received\", false], [\"Try to separate replies/signatures from plain body\", true]], {}, :class => 'input input--select'\n        %p.fieldSet__text\n          If enabled, we'll try to remove the replies/signatures from the plain body and send them separately to the rest of the body.\n          This is useful if you just want to see the latest message in a thread.\n    .fieldSet__field\n      = f.label :include_attachments, \"Attachments\", :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :include_attachments, [[\"Include attachment data\", true], [\"Don't include attachment data\", false]], {}, :class => 'input input--select'\n        %p.fieldSet__text\n          You can choose whether or not attachment data will be delivered to your app. This only applies when the message is delivered\n          as a hash (rather than the raw message - these will always have attachment data within).\n    .fieldSet__field\n      = f.label :timeout, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.text_field :timeout, :class => 'input input--text', :placeholder => \"Default: 5\"\n        %p.fieldSet__text\n          This is how long (in seconds) we should wait for your server to respond before giving up and trying again later. By default this is 5\n          seconds. The maximum value is 60 seconds.\n\n  .fieldSetSubmit.buttonSet\n    = f.submit @http_endpoint.new_record? ? \"Create HTTP endpoint\" : \"Save HTTP endpoint\", :class => 'button button--positive js-form-submit'\n    .fieldSetSubmit__delete\n      - if f.object.persisted?\n        = link_to \"Delete HTTP endpoint\", [organization, @server, @http_endpoint], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => \"Are you sure you wish to delete this HTTP endpoint?\\n\\r#{pluralize @http_endpoint.routes.size, 'route'} that uses this endpoint will also be deleted.\"}\n\n  = hidden_field_tag 'return_to', params[:return_to]\n  = hidden_field_tag 'return_notice', params[:return_notice]\n"
  },
  {
    "path": "app/views/http_endpoints/edit.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Routing\"\n- page_title << \"HTTP Endpoints\"\n- page_title << \"Edit\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :routing\n= render 'routes/header', :active_nav => :http_endpoints\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/http_endpoints/index.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Routing\"\n- page_title << \"HTTP Endpoints\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :routing\n= render 'routes/header', :active_nav => :http_endpoints\n.pageContent.pageContent--compact\n\n  - if @http_endpoints.empty?\n    .noData.noData--clean\n      %h2.noData__title There aren't any HTTP endpoints yet.\n      %p.noData__text\n        HTTP endpoints are essentially URLs that you'd like incoming e-mails\n        to be delivered to. Once you've added some endpoints, you can route messages\n        to them by creating #{link_to 'routes', [organization, @server, :routes], :class => 'u-link'}.\n      %p.noData__button\n        = link_to \"Add your first HTTP endpoint\", [:new, organization, @server, :http_endpoint], :class => 'button button--positive'\n\n  - else\n\n    %ul.endpointList.u-margin\n      - for endpoint in @http_endpoints\n        %li.endpointList__item\n          = link_to [:edit, organization, @server, endpoint], :class => 'endpointList__link' do\n            .endpointList__main\n              %p.endpointList__name= endpoint.name\n              %p.endpointList__url= endpoint.url\n            %ul.endpointList__details\n              %li.endpointList__detailItem= t(\"http_endpoint_encodings.#{endpoint.encoding.underscore}\")\n              %li.endpointList__detailItem= t(\"http_endpoint_formats.#{endpoint.format.underscore}\")\n              %li.endpointList__detailItem\n                - if endpoint.last_used_at\n                  Last used #{distance_of_time_in_words_to_now endpoint.last_used_at} ago\n                - else\n                  Not used yet\n\n    %p.u-center= link_to \"Add another HTTP endpoint\", [:new, organization, @server, :http_endpoint], :class => 'button button--positive'\n"
  },
  {
    "path": "app/views/http_endpoints/new.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Routing\"\n- page_title << \"HTTP Endpoints\"\n- page_title << \"New\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :routing\n= render 'routes/header', :active_nav => :http_endpoints\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/ip_addresses/_form.html.haml",
    "content": "= form_for [@ip_pool, @ip_address], :remote => true do |f|\n  = f.error_messages\n  %fieldset.fieldSet\n    .fieldSet__field\n      = f.label :ipv4, :class => 'fieldSet__label'\n      .fieldSet__input= f.text_field :ipv4, :autofocus => true, :class => 'input input--text'\n    .fieldSet__field\n      = f.label :ipv6, :class => 'fieldSet__label'\n      .fieldSet__input= f.text_field :ipv6, :class => 'input input--text'\n    .fieldSet__field\n      = f.label :hostname, :class => 'fieldSet__label'\n      .fieldSet__input= f.text_field :hostname, :class => 'input input--text'\n    .fieldSet__field\n      = f.label :priority, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.text_field :priority, :class => 'input input--text', placeholder: '100'\n        %p.fieldSet__text\n          This priority will determine the likelihood of this IP address being selected\n          for use when sending a message. The higher the number the more likely the IP\n          is to be chosen. By default, the priority is set to the maximum value of 100.\n          This can be used to warm up new IP addresses by adding them with a low priority.\n          To give an indication of how this works, if you have three IPs with 1, 50 and 100\n          as their priorities, and you send 100,000 emails, the priority 1 address will receive\n          a tiny percentage, the priority 50 will receive roughly one third of e-mails and the\n          priority 100 will receive roughly two thirds.\n\n  .fieldSetSubmit.buttonSet\n    = f.submit :class => 'button button--positive js-form-submit'\n    .fieldSetSubmit__delete\n      - if @ip_address.persisted?\n        = link_to \"Delete IP address\", [@ip_pool, @ip_address], :class => 'button button--danger', :method => :delete, :remote => true, :data => {:confirm => \"Are you sure you wish to remove this IP from the pool?\"}\n"
  },
  {
    "path": "app/views/ip_addresses/edit.html.haml",
    "content": "- page_title << \"IP Pools\"\n- page_title << @ip_pool.name\n- page_title << \"Edit IP address\"\n.pageHeader\n  %h1.pageHeader__title\n    %span.pageHeader__titlePrevious\n      = link_to \"IP Pools\", :ip_pools\n      &rarr;\n      = @ip_pool.name\n      &rarr;\n      Edit\n      &rarr;\n    Edit IP address\n\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/ip_addresses/new.html.haml",
    "content": "- page_title << \"IP Pools\"\n- page_title << @ip_pool.name\n- page_title << \"Add new IP address\"\n.pageHeader\n  %h1.pageHeader__title\n    %span.pageHeader__titlePrevious\n      = link_to \"IP Pools\", :ip_pools\n      &rarr;\n      = @ip_pool.name\n      &rarr;\n      Edit\n      &rarr;\n    Add new IP address\n\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/ip_pool_rules/_form.html.haml",
    "content": ".pageContent.pageContent--compact\n  = form_for [organization, @server, @ip_pool_rule], :remote => true do |f|\n    = f.error_messages\n    %fieldset.fieldSet\n      %h2.fieldSet__title.fieldSet__title--noMargin Rule match conditions\n      .fieldSet__field\n        = f.label :to_text, \"To Addresses\", :class => 'fieldSet__label'\n        .fieldSet__input\n          ~ f.text_area :to_text, :autofocus => true, :class => 'input input--text input--smallArea'\n          %p.fieldSet__text\n            This is a list of addresses or domains which should be matched. This\n            applies to e-mail address of the recipient of a message.\n\n      .fieldSet__field\n        = f.label :from_text, \"From Addresses\", :class => 'fieldSet__label'\n        .fieldSet__input\n          ~ f.text_area :from_text, :class => 'input input--text input--smallArea'\n          %p.fieldSet__text\n            This is a list of addresses or domains which should be matched. This\n            applies to value <code>From</code> in the From header of the message\n            that is being delivered.\n    %fieldset.fieldSet\n      %h2.fieldSet__title Selected IP Pool\n      .fieldSet__field\n        = f.label :ip_pool_id, \"IP Pool\", :class => 'fieldSet__label'\n        .fieldSet__input\n          = f.collection_select :ip_pool_id, organization.ip_pools.includes(:ip_addresses).order(\"`default` desc, name asc\"), :id, :name, {}, :class => 'input input--select'\n          %p.fieldSet__text\n            This is the IP pool that this message should be delivered from.\n\n    .fieldSetSubmit\n      = f.submit \"Save Rule\", :class => \"button button--positive js-form-submit\"\n      .fieldSetSubmit__delete\n        - if f.object.persisted?\n          = link_to \"Delete Rule\", [organization, @server, @ip_pool_rule], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => \"Are you sure you wish to delete this rule?\"}\n"
  },
  {
    "path": "app/views/ip_pool_rules/edit.html.haml",
    "content": "- if @server\n  - page_title << @server.name\n- page_title << \"Edit IP Pool Rule\"\n\n- if @server\n  = render 'servers/sidebar', :active_server => @server\n  = render 'servers/header', :active_nav => :settings\n  = render 'servers/settings_header', :active_nav => :ip_pool_rules\n- else\n  .pageHeader\n    %h1.pageHeader__title\n      %span.pageHeader__titlePrevious\n        = @organization.name\n        &rarr;\n        IP Pool Rules\n        &rarr;\n      Edit rule\n  = render 'organizations/nav', :active_nav => :ips\n  = render 'organization_ip_pools/nav', :active_nav => :rules\n= render 'form'\n"
  },
  {
    "path": "app/views/ip_pool_rules/index.html.haml",
    "content": "- if @server\n  - page_title << @server.name\n  - page_title << \"IP Pool Rules\"\n- else\n  - page_title << \"IPs\"\n  - page_title << \"Rules\"\n\n- if @server\n  = render 'servers/sidebar', :active_server => @server\n  = render 'servers/header', :active_nav => :settings\n  = render 'servers/settings_header', :active_nav => :ip_pool_rules\n- else\n  .pageHeader\n    %h1.pageHeader__title\n      %span.pageHeader__titlePrevious\n        = @organization.name\n        &rarr;\n      IP Pool Rules\n  = render 'organizations/nav', :active_nav => :ips\n  = render 'organization_ip_pools/nav', :active_nav => :rules\n\n.pageContent.pageContent--compact\n  - if @ip_pool_rules.empty?\n    .noData.noData--clean\n      - if @server.nil?\n        %h2.noData__title No global rules have been configured yet.\n        %p.noData__text\n          You can use IP pool rules to configure which IP addresses to use based on the\n          message that are passing through Postal. You can add rules globally or on a\n          per-server basis.\n        %p.noData__button= link_to \"Add a global rule\", [:new, organization, @server, :ip_pool_rule], :class => \"button button--positive\"\n      - else\n        %h2.noData__title No IP rules have been configured for this server yet.\n        %p.noData__text\n          You can use IP pool rules to configure which IP addresses to use based on the\n          message that are passing through Postal. You can add rules globally or on a\n          per-server basis.\n        %p.noData__button= link_to \"Add a server rule\", [:new, organization, @server, :ip_pool_rule], :class => \"button button--positive\"\n        -\n  - else\n    .ipPoolRuleList.u-margin\n      - for ip_pool_rule in @ip_pool_rules\n        .ipPoolRuleList__item\n          = link_to [:edit, organization, @server, ip_pool_rule], :class => 'ipPoolRuleList__link' do\n            - if ip_pool_rule.to.present?\n              %dl.ipPoolRuleList__condition\n                %dt Any messages sent to:\n                %dd\n                  %ul\n                    - for a in ip_pool_rule.to\n                      %li= a\n            - if ip_pool_rule.from.present?\n              %dl.ipPoolRuleList__condition\n                %dt Any message sent from:\n                %dd\n                  %ul\n                    - for a in ip_pool_rule.from\n                      %li= a\n\n            %dl.ipPoolRuleList__condition\n              %dt Will be sent using:\n              %dd= ip_pool_rule.ip_pool.name\n    - if @server && @server.ip_pool\n      %p.ipPoolRuleListDefault.u-margin All mail that doesn't match a rule above will be sent using #{@server.ip_pool.name}.\n    %p.u-center= link_to \"Add another rule\", [:new, organization, @server, :ip_pool_rule], :class => \"button button--positive\"\n"
  },
  {
    "path": "app/views/ip_pool_rules/new.html.haml",
    "content": "- if @server\n  - page_title << @server.name\n- page_title << \"Add IP Pool Rule\"\n\n- if @server\n  = render 'servers/sidebar', :active_server => @server\n  = render 'servers/header', :active_nav => :settings\n  = render 'servers/settings_header', :active_nav => :ip_pool_rules\n- else\n  .pageHeader\n    %h1.pageHeader__title\n      %span.pageHeader__titlePrevious\n        = @organization.name\n        &rarr;\n        IP Pool Rules\n        &rarr;\n      Add new rule\n  = render 'organizations/nav', :active_nav => :ips\n  = render 'organization_ip_pools/nav', :active_nav => :rules\n\n= render 'form'\n"
  },
  {
    "path": "app/views/ip_pools/_form.html.haml",
    "content": "= form_for @ip_pool, :remote => true do |f|\n  = f.error_messages\n  %fieldset.fieldSet.u-margin\n    .fieldSet__field\n      = f.label :name, :class => 'fieldSet__label'\n      .fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'\n\n  - if @ip_pool.persisted?\n    %table.dataTable.u-margin-half\n      %thead\n        %tr\n          %td IPv4\n          %td IPv6\n          %td Hostname\n          %td Priority\n      %tbody\n        - ips = @ip_pool.ip_addresses.order_by_priority\n        - if ips.empty?\n          %tr\n            %td.dataTable__empty{:colspan => 3} There are no IP addresses assigned to this pool yet.\n        - else\n          - for ip in ips\n            %tr\n              %td{:width => \"20%\"}= link_to ip.ipv4, [:edit, @ip_pool, ip], :class => \"u-link\"\n              %td{:width => \"35%\"}= ip.ipv6\n              %td{:width => \"35%\"}= ip.hostname\n              %td{:width => \"10%\"}= ip.priority\n    %p= link_to \"Add an IP address to pool\", [:new, @ip_pool, :ip_address], :class => \"u-link\"\n\n\n  .fieldSetSubmit.buttonSet\n    = f.submit :class => 'button button--positive js-form-submit'\n    .fieldSetSubmit__delete\n      - if @ip_pool.persisted?\n        = link_to \"Delete IP pool\", [@ip_pool], :class => 'button button--danger', :method => :delete, :remote => true, :data => {:confirm => \"Are you sure you wish to remove this IP pool?\"}\n\n\n"
  },
  {
    "path": "app/views/ip_pools/edit.html.haml",
    "content": "- page_title << \"IP Pools\"\n- page_title << @ip_pool.name\n\n.pageHeader\n  %h1.pageHeader__title\n    %span.pageHeader__titlePrevious\n      = link_to \"IP Pools\", :ip_pools\n      &rarr;\n      = @ip_pool.name\n      &rarr;\n    Edit\n\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/ip_pools/index.html.haml",
    "content": "- page_title << \"Welcome\"\n\n.pageHeader\n  %h1.pageHeader__title IP Pools\n\n.pageContent.pageContent--compact\n\n  - if @ip_pools.empty?\n    .noData.noData--clean\n      %p.noData__title There are no IP pools configured.\n      %p.noData__text\n        All messages sent from your mail server can be sent from certain pools of\n        IP addresses. Each server can be assigned to a pool and rules can be configured\n        to route certain email through certain pools.\n      %p.noData__button= link_to \"Create the first IP pool\", :new_ip_pool, :class => 'button button--positive'\n  - else\n    %p.pageContent__intro.u-margin\n      IP pools are the addresses that your outgoing messages are sent from. You can\n      create as many pools as you wish.\n\n    %ul.largeList.u-margin\n      - for ip_pool in @ip_pools\n        %li.largeList__item\n          = link_to edit_ip_pool_path(ip_pool), :class => 'largeList__link' do\n            = ip_pool.name\n\n    %p.u-center= link_to \"Add another IP pool\", :new_ip_pool, :class => 'button button--positive'\n"
  },
  {
    "path": "app/views/ip_pools/new.html.haml",
    "content": "- page_title << \"Create a new IP pool\"\n.pageHeader\n  %h1.pageHeader__title\n    %span.pageHeader__titlePrevious\n      = link_to \"IP Pools\", :ip_pools\n      &rarr;\n    Create a new IP pool\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/layouts/application.html.haml",
    "content": "!!!\n%html.main\n  %head\n    %title #{page_title.reverse.join(' - ')}\n    = csrf_meta_tags\n    = stylesheet_link_tag 'application/application', 'data-turbolinks-track' => 'reload'\n    = javascript_include_tag 'application/application', 'data-turbolinks-track' => 'reload'\n    %link{:href => asset_path('favicon.png'), :rel => 'shortcut icon'}\n    <meta name=\"turbolinks-cache-control\" content=\"no-cache\">\n    = yield :head\n  %body\n    = display_flash\n    %header.siteHeader{'data-turbolinks-permanent' => true}\n      - if flash[:remember_login] && !auth_session.persistent?\n        .siteHeader__remember.js-remember\n          .siteHeader__rememberText\n            %p.siteHeader__rememberTextTitle Would you like to stay logged in?\n            %p This will keep you logged in in this browser for 2 months.\n          .siteHeader__rememberButtons.buttonSet\n            = link_to \"Remember me\", '#', :class => 'button button--positive button--small', :data => {:remember => 'yes'}\n            = link_to \"Close\", '#', :class => 'button button--dark button--small', :data => {:remember => 'no'}\n\n      .siteHeader__inside\n        .siteHeader__logo= link_to \"Postal\", root_path\n        %p.siteHeader__version The open source e-mail platform\n        %ul.siteHeader__nav\n          - if defined?(organization) && organization\n            %li.siteHeader__navItem\n            %li.siteHeader__navItem.siteHeader__navItem--organization\n              = link_to organization.name, organization_root_path(organization), :class => 'siteHeader__navLinkWithMenu'\n              %ul.siteHeader__subMenu\n                %li.siteHeader__subMenuItem.siteHeader__subMenuItem--header= link_to organization.name, organization_root_path(organization)\n                %li.siteHeader__subMenuItem= link_to \"Mail servers\", organization_root_path(organization), :class => 'siteHeader__subMenuLink'\n                %li.siteHeader__subMenuItem= link_to \"Domains\", organization_domains_path(organization), :class => 'siteHeader__subMenuLink'\n                %li.siteHeader__subMenuItem= link_to \"Organization Settings\", organization_settings_path(organization), :class => 'siteHeader__subMenuLink'\n                - if current_user.admin?\n                  %li.siteHeader__subMenuItem= link_to \"Create new organization\", :new_organization, :class => 'siteHeader__subMenuLink'\n                - if current_user.organizations.present.count > 1\n                  %li.siteHeader__subMenuItem= link_to \"Switch organization\", root_path, :class => 'siteHeader__subMenuLink'\n          %li.siteHeader__navItem.siteHeader__navItem--user= current_user.name\n          %li.siteHeader__navItem= link_to \"My Settings\", settings_path, :class => 'sideHeader__navItemLink'\n          - if current_user.admin?\n            - if Postal.ip_pools?\n              %li.siteHeader__navItem= link_to \"IP Pools\", ip_pools_path, :class => 'sideHeader__navItemLink'\n            %li.siteHeader__navItem= link_to \"Users\", users_path, :class => 'sideHeader__navItemLink'\n          %li.siteHeader__navItem= link_to \"Logout\", logout_path, :method => :delete, :class => 'sideHeader__navItemLink'\n\n    .siteContent\n      - if content_for?(:sidebar)\n        %nav.sidebar\n          = content_for :sidebar\n\n      %section.siteContent__main\n        = yield\n        %footer.siteContent__footer\n          %ul.footer__links\n            %li.footer__name\n              Powered by\n              #{link_to \"Postal\", \"https://postalserver.io\", target: '_blank'}\n              #{postal_version_string}\n            %li= link_to \"Documentation\", \"https://docs.postalserver.io\", target: '_blank'\n            %li= link_to \"Ask for help\", \"https://discussions.postalserver.io\", target: '_blank'\n"
  },
  {
    "path": "app/views/layouts/sub.html.haml",
    "content": "!!!\n%html.subPage\n  %head\n    %title #{page_title.reverse.join(' - ')}\n    = csrf_meta_tags\n    = stylesheet_link_tag 'application/application', 'data-turbolinks-track' => 'reload'\n    = javascript_include_tag 'application/application', 'data-turbolinks-track' => 'reload'\n    %link{:href => asset_path('favicon.png'), :rel => 'shortcut icon'}\n    <meta name=\"turbolinks-cache-control\" content=\"no-cache\">\n  %body\n    .subPageBox{:class => @wide ? \"subPageBox--wide\" : ''}\n      = yield\n\n"
  },
  {
    "path": "app/views/messages/_deliveries.html.haml",
    "content": "%ul.deliveryList\n  - if message.queued_message && message.queued_message.locked?\n    %li.deliveryList__item.deliveryList__item--header\n      %p Message is currently being processed.\n  - elsif message.queued_message && message.queued_message.retry_after\n    %li.deliveryList__item.deliveryList__item--header\n      %p This message will be retried automatically in #{distance_of_time_in_words_to_now message.queued_message.retry_after}.\n      %p= link_to \"Retry delivery now\", retry_organization_server_message_path(organization, @server, message.id), :class => \"button button--small\", :remote => true, :method => :post\n  - elsif message.held?\n    %li.deliveryList__item.deliveryList__item--header\n      %p\n        This message has been held. By releasing the message, we will allow it to continue on its way to its destination.\n        - if @message.hold_expiry\n          It will be held until #{@message.hold_expiry.to_fs(:long)}.\n      %p.buttonSet\n        = link_to \"Release message\", retry_organization_server_message_path(organization, @server, message.id), :class => \"button button--small\", :remote => true, :method => :post\n        = link_to \"Cancel hold\", cancel_hold_organization_server_message_path(organization, @server, message.id), :class => \"button button--small button--danger\", :remote => true, :method => :post\n  - elsif @server.mode == 'Development'\n    %li.deliveryList__item.deliveryList__item--header\n      %p This server is in development mode so this message can be redelivered as if it had just been received.\n      %p= link_to \"Redeliver message\", retry_organization_server_message_path(organization, @server, message.id), :class => \"button button--small\", :remote => true, :method => :post\n  - else\n    %li.deliveryList__item.deliveryList__item--header\n      %p This message can be redelivered as if it had just been received.\n      %p= link_to \"Redeliver message\", retry_organization_server_message_path(organization, @server, message.id), :class => \"button button--small\", :remote => true, :method => :post\n\n  - if message.deliveries.empty?\n    %li.deliveryList__item\n      .noData.noData--clean\n        %h2.noData__text No delivery attempts yet.\n  - else\n    - for delivery in message.deliveries.reverse\n      %li.deliveryList__item\n        .deliveryList__top\n          .deliveryList__time\n            = delivery.timestamp.to_fs(:long)\n          .deliveryList__status\n            - if delivery.sent_with_ssl\n              = image_tag 'icons/lock.svg', :class => 'deliveryList__secure'\n            %span.label.label--large{:class => \"label--messageStatus-#{delivery.status.underscore}\"}= delivery.status.underscore.humanize\n        - if delivery.details\n          %p.deliveryList__error= format_delivery_details(@server, delivery.details)\n        - if delivery.log_id || delivery.output\n          = link_to \"Show technical details\", '#', :class => 'js-toggle js-tech-link deliveryList__techLink', :data => {:element => '.js-tech-link, .js-tech-output'}\n          .deliveryList__error.deliveryList__error--output.js-tech-output.is-hidden\n            %p.deliveryList__error--output-text= delivery.output\n            - if delivery.time\n              %p.deliveryList__error--output-ref Time: #{delivery.time}s\n            - if delivery.log_id\n              %p.deliveryList__error--output-ref Support Ref: #{delivery.log_id}\n- if message.queued_message && !message.queued_message.locked?\n  %p.deliveryList-removeLink= link_to \"Remove from queue\", remove_from_queue_organization_server_message_path(organization, @server, message.id), :method => :delete, :remote => true, :data => {:disable_with => \"Removing...\", :confirm => \"Are you sure you wish to remove this message from the queue?\"}, :class => \"u-link\"\n"
  },
  {
    "path": "app/views/messages/_header.html.haml",
    "content": ".navBar.navBar--secondary\n  %ul\n    %li.navBar__item= link_to \"Outgoing Messages\", [:outgoing, organization, @server, :messages], :class => ['navBar__link', active_nav == :outgoing ? 'is-active' : '']\n    %li.navBar__item= link_to \"Incoming Messages\", [:incoming, organization, @server, :messages], :class => ['navBar__link', active_nav == :incoming ? 'is-active' : '']\n    %li.navBar__item= link_to \"Queue\", [:queue, organization, @server], :class => ['navBar__link', active_nav == :queue ? 'is-active' : '']\n    %li.navBar__item= link_to \"Held\", [:held, organization, @server, :messages], :class => ['navBar__link', active_nav == :held ? 'is-active' : '']\n    %li.navBar__item= link_to \"Send Message\", [:new, organization, @server, :message], :class => ['navBar__link', active_nav == :new ? 'is-active' : '']\n    %li.navBar__item= link_to \"Suppressions\", [:suppressions, organization, @server, :messages], :class => ['navBar__link', active_nav == :suppressions ? 'is-active' : '']\n"
  },
  {
    "path": "app/views/messages/_index.html.haml",
    "content": ".pageContent.js-ajax-region\n  - if @searchable\n    = render 'search'\n\n  - if @messages[:records].empty?\n    .noData.noData--clean\n      %h2.noData__title No messages found matching your filter.\n      %p.noData__text\n        There were no messages which matched the query that you entered. Sorry about that.\n  - else\n    = render 'list', :messages => @messages[:records]\n    = render 'shared/message_db_pagination', :data => @messages, :name => \"message\"\n\n"
  },
  {
    "path": "app/views/messages/_list.html.haml",
    "content": "%ul.messageList\n  - for message in messages\n  \n    - if message.is_a?(QueuedMessage)\n      - queued_message = message\n      - message = message.message\n      \n    \n    - if message.nil? && queued_message\n      %li.messageList__message\n        .messageList__link\n          .messageList__details\n            %p.messageList__subject Deleted message ##{queued_message.message_id}\n            %dl.messageList__addresses\n              %dt Domain\n              %dd= queued_message.domain\n              %dt Locked\n              %dd= queued_message.locked? ? \"Yes\" : \"No\"\n          .messageList__meta\n            %p.messageList__timestamp= queued_message.created_at.in_time_zone.to_fs(:long)\n            %p.messageList__status\n              %span.label{:class => \"label--messageStatus-deleted\"} Deleted\n\n\n    - else\n      %li.messageList__message\n        = link_to organization_server_message_path(organization, @server, message.id), :class => 'messageList__link' do\n          .messageList__details{:class => 'messageList__details--' + message.scope}\n            %p.messageList__subject= message.subject || \"No subject\"\n            %dl.messageList__addresses\n              %dt To\n              %dd\n                - if message.rcpt_to_return_path?\n                  %span.returnPathTag Return Path\n                - else\n                  = message.rcpt_to || \"none\"\n              %dt From\n              %dd= message.mail_from || \"none\"\n              - if queued_message\n                %dt Attempts\n                %dd= queued_message.attempts\n                %dt Retry after\n                %dd= queued_message.retry_after&.to_fs(:short) || \"ASAP\"\n\n          .messageList__meta\n            %p.messageList__timestamp= message.timestamp.in_time_zone.to_fs(:long)\n            %p.messageList__status\n              - if message.read?\n                %span.label.label--purple Opened\n              %span.label{:class => \"label--messageStatus-#{message.status.underscore}\"}= message.status.underscore.humanize\n"
  },
  {
    "path": "app/views/messages/_message_header.html.haml",
    "content": ".messageHeader\n  .messageHeader__header{:class => \"messageHeader__header--#{@message.scope}\"}\n    %p.messageHeader__status\n      %span.label{:class => \"label--messageStatus-#{@message.status.underscore}\"}= @message.status.underscore.humanize\n    %h2.messageHeader__subject\n      = @message.subject || \"No subject\"\n\n    .messageHeader__basicProperties\n      %dl\n        %dt From\n        %dd\n          - if @message.mail_from\n            = link_to @message.mail_from || \"[blank]\", send(\"#{@message.scope}_organization_server_messages_path\", organization, @server, :query => \"from: #{@message.mail_from}\"), :class => 'u-link'\n          - else\n            None\n\n      %dl\n        %dt To\n        %dd\n          - if @message.rcpt_to_return_path?\n            %span.returnPathTag.returnPathTag--inMessageHeader= link_to \"Return Path\", send(\"#{@message.scope}_organization_server_messages_path\", organization, @server, :query => \"to: #{@message.rcpt_to}\"), :class => 'u-link'\n          - else\n            = link_to @message.rcpt_to || \"[blank]\", send(\"#{@message.scope}_organization_server_messages_path\", organization, @server, :query => \"to: #{@message.rcpt_to}\"), :class => 'u-link'\n      %dl\n        %dt Received\n        %dd= @message.timestamp.in_time_zone.to_fs(:long)\n\n.navBar.navBar--tertiary\n  %ul\n    %li.navBar__item= link_to \"Properties\", organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :properties ? 'is-active' : '']\n    %li.navBar__item= link_to \"Activity\", activity_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :activity ? 'is-active' : '']\n    %li.navBar__item= link_to \"Headers\", headers_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :headers ? 'is-active' : '']\n    %li.navBar__item= link_to \"Spam Checks\", spam_checks_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :spam_checks ? 'is-active' : '']\n    %li.navBar__item= link_to \"Plain Text\", plain_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :plain ? 'is-active' : '']\n    %li.navBar__item= link_to \"HTML\", html_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :html ? 'is-active' : '']\n    %li.navBar__item= link_to \"Attachments\", attachments_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :attachments ? 'is-active' : '']\n    - if @message.raw_message?\n      %li.navBar__item= link_to \"Download\", download_organization_server_message_path(organization, @server, @message.id), :data => {:turbolinks => 'false'}, :class =>'navBar__link'\n"
  },
  {
    "path": "app/views/messages/_search.html.haml",
    "content": "= form_tag request.fullpath, :method => :get, :remote => true, :class => 'messageSearch', :enforce_utf8 => false do\n  %p\n    = link_to \"Need help with filtering?\", '#', :class => 'messageSearch__help js-toggle-helpbox'\n    = text_field_tag 'query', @query, :class => 'messageSearch__input js-focus-on-f js-form-submit', :placeholder => \"Filter messages...\", :data => {:disable_with => 'Searching...'}\n\n  .messageSearch__helpBox.is-hidden.js-helpbox\n    .messageSearch__left\n      %h3.messageSearch__helpBoxTitle\n        Filtering your messages\n      %p.messageSearch__helpBoxText\n        You can filter your messages on a number of attributes. At present, it is not possible to\n        search the content of your messages. To filter though, you can insert any of the strings\n        as shown opposite into the box above and press enter.\n    .messageSearch__right\n      %dl.messageSearch__definition\n        %dt to: rachel@example.com\n        %dd Returns all mail addressed to the address provided.\n      %dl.messageSearch__definition\n        %dt from: tom@example.com\n        %dd Returns all mail sent from to the address provided.\n      %dl.messageSearch__definition\n        %dt status: pending\n        %dd Returns all messages with the status provided. The suitable statuses are: <code>pending</code>, <code>sent</code>, <code>held</code>, <code>softfail</code>, <code>hardfail</code> and <code>bounced</code>.\n      %dl.messageSearch__definition\n        %dt before: yyyy-mm-dd hh:mm\n        %dd Returns any message received before the given timestamp.\n      %dl.messageSearch__definition\n        %dt after: yyyy-mm-dd hh:mm\n        %dd Returns any message received after the given timestamp.\n      %dl.messageSearch__definition\n        %dt msgid:  57f3a85b35545@server01.mail\n        %dd Returns any message with the given Message-ID header.\n      %dl.messageSearch__definition\n        %dt tag: password-reset\n        %dd Returns any message tagged with the tag provided.\n      %dl.messageSearch__definition\n        %dt spam: yes\n        %dd By default, spam is not shown in results. To show spam instead of non-spam, just add this to the query.\n      %dl.messageSearch__definition\n        %dt order: oldest-first\n        %dd By default, newest messages are shown first. To show oldest messages first, you can add this.\n"
  },
  {
    "path": "app/views/messages/activity.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Messages\"\n- page_title << \"Message ##{@message.id}\"\n- page_title << \"Activity\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :messages\n= render 'header', :active_nav => @message.scope.to_sym\n= render 'message_header', :active_nav => :activity\n.pageContent.pageContent--compact\n  %ul.messageActivity\n    - for entry in @entries.reverse\n      - if entry.is_a?(Postal::MessageDB::Delivery)\n        %li.messageActivity__event\n          %p.messageActivity__timestamp= entry.timestamp.to_fs(:long)\n          .messageActivity__details.messageActivity--detailsDelivery\n            %p.messageActivity__subject\n              =# entry.status.underscore.humanize\n              %span.label.label--large{:class => \"label--messageStatus-#{entry.status.underscore}\"}= entry.status.underscore.humanize\n\n            %p.messageActivity__extra= entry.details\n\n      - elsif entry.is_a?(Postal::MessageDB::Click)\n        %li.messageActivity__event\n          %p.messageActivity__timestamp= entry.timestamp.to_fs(:long)\n          .messageActivity__details.messageActivity--detailsClick\n            %p.messageActivity__subject Click for #{entry.url}\n            %p.messageActivity__extra Clicked from #{entry.ip_address} (#{entry.user_agent})\n\n      - elsif entry.is_a?(Postal::MessageDB::Load)\n        %li.messageActivity__event\n          %p.messageActivity__timestamp= entry.timestamp.to_fs(:long)\n          .messageActivity__details.messageActivity--detailsLoad\n            %p.messageActivity__subject Message Viewed\n            %p.messageActivity__extra Opened from #{entry.ip_address} (#{entry.user_agent})\n\n    %li.messageActivity__event\n      %p.messageActivity__timestamp= @message.timestamp.to_fs(:long)\n      .messageActivity__details\n        %p.messageActivity__subject\n          Message received by Postal\n        %p.messageActivity__extra\n          - if @message.credential\n            Received using the #{@message.credential.name} #{@message.credential.type} credential.\n          - if @message.received_with_ssl\n            Connection secured with SSL.\n"
  },
  {
    "path": "app/views/messages/attachments.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Messages\"\n- page_title << \"Message ##{@message.id}\"\n- page_title << \"Attachments\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :messages\n= render 'header', :active_nav => @message.scope.to_sym\n= render 'message_header', :active_nav => :attachments\n.pageContent.pageContent--compact\n  - if @message.attachments.empty?\n    .noData.noData--clean\n      %h2.noData__title There are no attachments for this message.\n      %p.noData__text\n        This means that we no longer store the raw data for this e-mail\n        or the e-mail just didn't have any attached files.\n  - else\n    %ul.largeList\n      - @message.attachments.each_with_index do |attachment, i|\n        %li.largeList__item\n          = link_to attachment_organization_server_message_path(organization, @server, @message.id, :attachment => i), :class => 'largeList__link', :data => {:turbolinks => \"false\"} do\n            %p.largeList__rightLabel= number_to_human_size attachment.body.to_s.bytesize\n            %p= attachment.filename\n            %p.largeList__subText= attachment.mime_type\n"
  },
  {
    "path": "app/views/messages/headers.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Messages\"\n- page_title << \"Message ##{@message.id}\"\n- page_title << \"Headers\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :messages\n= render 'header', :active_nav => @message.scope.to_sym\n= render 'message_header', :active_nav => :headers\n\n- if @message.headers.empty?\n  .pageContent.pageContent--compact\n    .noData.noData--clean\n      %h2.noData__title There are no headers for this message.\n      %p.noData__text\n        This means that we no longer store the raw data for this e-mail.\n\n- else\n  .pageContent\n    .headersList\n      - for key, values in @message.headers\n        - for value in values\n          %dl.headersList__item\n            %dt= key\n            %dd= value\n\n"
  },
  {
    "path": "app/views/messages/held.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Messages\"\n- page_title << \"Held\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :messages\n= render 'header', :active_nav => :held\n- if @messages.empty? && !@queried\n  .pageContent--compact\n    .noData.noData--clean\n      %h2.noData__title You haven't got any held messages.\n      %p.noData__text\n        You haven't sent any messages through this mail server yet. Not to worry though\n        they'll start appearing here as soon as you start sending them.\n- else\n  = render 'index'\n\n"
  },
  {
    "path": "app/views/messages/html.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Messages\"\n- page_title << \"Message ##{@message.id}\"\n- page_title << \"HTML\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :messages\n= render 'header', :active_nav => @message.scope.to_sym\n= render 'message_header', :active_nav => :html\n- if @message.html_body.blank?\n  .pageContent.pageContent--compact\n    .noData.noData--clean\n      %h2.noData__title There's no HTML body for this message.\n      %p.noData__text\n        This means that we no longer store the raw data for this e-mail\n        or the e-mail didn't include a HTML part.\n- else\n  %iframe{:width => \"100%\", :height => \"100%\", :src => html_raw_organization_server_message_path(organization, @server, @message.id)}\n"
  },
  {
    "path": "app/views/messages/incoming.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Messages\"\n- page_title << \"Incoming\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :messages\n= render 'header', :active_nav => :incoming\n- if @messages[:records].empty? && !@queried\n  .pageContent--compact\n    .noData.noData--clean\n      %h2.noData__title No messages have been received yet.\n      %p.noData__text\n        You haven't received any messages through this mail server yet. Not to worry though\n        they'll start appearing here as soon as you start receiving them.\n      %p.noData__button\n        = link_to \"View spam messages\", incoming_organization_server_messages_path(organization, @server, :query => \"spam: yes\"), :class => \"button button--neutral\"\n\n- else\n  = render 'index'\n"
  },
  {
    "path": "app/views/messages/new.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Messages\"\n- page_title << \"Send\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :messages\n= render 'header', :active_nav => :new\n.pageContent.pageContent--compact\n  %p.pageContent__intro.u-margin\n    You can use this form to send a message through this mail server. This is useful\n    for testing and debugging purposes.\n  - if @message.is_a?(OutgoingMessagePrototype)\n    %p.pageContent__text.u-margin.newMessageType.newMessageType--outgoing\n      <b>You are sending an outgoing message.</b> This e-mail will be routed as if it was an e-mail sent from your mail server.\n      = link_to \"Simulate an incoming e-mail instead?\", {:direction => 'incoming'}, :class => 'u-link'\n  - else\n    %p.pageContent__text.u-margin.newMessageType.newMessageType--incoming\n      <b>You are sending an incoming message.</b> This e-mail will can only be sent to your routes and will behave as if it was received by your mail server.\n      = link_to \"Simulate an outgoing e-mail instead?\", {:direction => 'outgoing'}, :class => 'u-link'\n  = form_tag [organization, @server, :messages], :remote => true do\n    = hidden_field_tag 'direction', params[:direction]\n    .fieldSet\n      - if @message.is_a?(OutgoingMessagePrototype)\n        .fieldSet__field\n          = label_tag :message_from, \"From \", :class => 'fieldSet__label'\n          .fieldSet__input\n            = text_field_tag \"message[from]\", @message.from, :autofocus => true, :class => 'input input--text'\n            %p.fieldSet__text\n              Enter the address that you wish to wish to send the message from. This must be\n              an address which exists at one of your verified domains.\n        .fieldSet__field\n          = label_tag :message_to, \"To\", :class => 'fieldSet__label'\n          .fieldSet__input= text_field_tag \"message[to]\", @message.to, :class => 'input input--text'\n      - else\n        .fieldSet__field\n          = label_tag :message_route_id, \"Route\", :class => 'fieldSet__label'\n          .fieldSet__input= text_field_tag \"message[to]\", @message.to, :class => 'input input--text'\n\n        .fieldSet__field\n          = label_tag :message_from, \"From\", :class => 'fieldSet__label'\n          .fieldSet__input= text_field_tag \"message[from]\", @message.from, :class => 'input input--text'\n      .fieldSet__field\n        = label_tag :message_subject, \"Subject\", :class => 'fieldSet__label'\n        .fieldSet__input= text_field_tag \"message[subject]\", @message.subject, :class => 'input input--text'\n      .fieldSet__field\n        = label_tag :message_plain_body, \"Body\", :class => 'fieldSet__label'\n        .fieldSet__input= text_area_tag \"message[plain_body]\", @message.plain_body, :class => 'input input--area'\n    .fieldSetSubmit.buttonSet\n      = submit_tag \"Send Message\", :class => 'button button--positive js-form-submit'\n\n"
  },
  {
    "path": "app/views/messages/outgoing.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Messages\"\n- page_title << \"Outgoing\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :messages\n= render 'header', :active_nav => :outgoing\n- if @messages[:records].empty? && !@queried\n  .pageContent--compact\n    .noData.noData--clean\n      %h2.noData__title No messages have been sent yet.\n      %p.noData__text\n        You haven't sent any messages through this mail server yet. Not to worry though\n        they'll start appearing here as soon as you start sending them.\n- else\n  = render 'index'\n\n"
  },
  {
    "path": "app/views/messages/plain.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Messages\"\n- page_title << \"Message ##{@message.id}\"\n- page_title << \"Plain Text\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :messages\n= render 'header', :active_nav => @message.scope.to_sym\n= render 'message_header', :active_nav => :plain\n.pageContent.pageContent--compact\n  - if @message.plain_body.blank?\n    .noData.noData--clean\n      %h2.noData__title There's no plain text body for this message.\n      %p.noData__text\n        This means that we no longer store the raw data for this e-mail\n        or the e-mail didn't include a plain text part.\n  - else\n    %pre.codeBlock= @message.plain_body\n"
  },
  {
    "path": "app/views/messages/show.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Messages\"\n- page_title << \"Message ##{@message.id}\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :messages\n= render 'header', :active_nav => @message.scope.to_sym\n= render 'message_header', :active_nav => :properties\n.pageContent\n  .messagePropertiesPage\n    .messagePropertiesPage__left\n      .messagePropertiesPage__propertyPair\n        %dl.messagePropertiesPage__property\n          %dt Spam Status\n          %dd\n            = link_to spam_checks_organization_server_message_path(organization, @server, @message.id) do\n              %span.label.label--large{:class => \"label--spamStatus-#{@message.spam_status.underscore}\"}= @message.spam_status.underscore.humanize\n        %dl.messagePropertiesPage__property\n          %dt Tag\n          %dd= @message.tag ? link_to(@message.tag, send(\"#{@message.scope}_organization_server_messages_path\", organization, @server, :query => \"tag: #{@message.tag}\"), :class => \"u-link\") : \"Not tagged\"\n      .messagePropertiesPage__propertyPair\n        %dl.messagePropertiesPage__property\n          %dt Raw Message\n          %dd= @message.raw_message? ? \"Available\" : \"Removed\"\n        %dl.messagePropertiesPage__property\n          %dt Message Size\n          %dd= @message.size ? number_to_human_size(@message.size) : \"n/a\"\n\n      .messagePropertiesPage__propertyPair\n        - if @message.scope == 'incoming'\n          %dl.messagePropertiesPage__property\n            %dt Route\n            %dd\n              - if @message.route\n                = link_to @message.route.name, [:edit, organization, @server, @message.route], :class => \"u-link\"\n              - else\n                Unknown Route\n          %dl.messagePropertiesPage__property\n            %dt Domain\n            %dd\n              - if @message.domain\n                = link_to @message.domain.name, [organization, @server, :domains], :class => \"u-link\"\n              - else\n                Unknown Domain\n        - else\n          %dl.messagePropertiesPage__property\n            %dt Credential\n            %dd\n              - if @message.credential\n                = link_to @message.credential.name, [:edit, organization, @server, @message.credential], :class => \"u-link\"\n              - else\n                Unknown Credential\n          %dl.messagePropertiesPage__property\n            %dt Domain\n            %dd\n              - if @message.domain\n                = link_to @message.domain.name, [organization, @server, :domains], :class => \"u-link\"\n              - else\n                Unknown Domain\n      - if @message.threat\n        %dl.messagePropertiesPage__property\n          %dt Threat\n          %dd= @message.threat_details\n      %dl.messagePropertiesPage__property\n        %dt Message ID\n        %dd= @message.message_id || \"No message ID\"\n      - unless @message.received_with_ssl.nil?\n        %dl.messagePropertiesPage__property\n          %dt Transport Security\n          - if @message.received_with_ssl\n            %dd.messagePropertiesPage__property--locked Received over an SSL connection\n          - else\n            %dd Not received with SSL\n\n    .messagePropertiesPage__right\n      = render 'deliveries', :message => @message\n\n"
  },
  {
    "path": "app/views/messages/spam_checks.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Messages\"\n- page_title << \"Message ##{@message.id}\"\n- page_title << \"Spam checks\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :messages\n= render 'header', :active_nav => @message.scope.to_sym\n= render 'message_header', :active_nav => :spam_checks\n.pageContent.pageContent--compact\n\n  - if @spam_checks.empty?\n    .noData.noData--clean\n      %h2.noData__title This message doesn't have any spam checks.\n      %p.noData__text\n        This likely means we haven't scanned this message to determine its likelyhood\n        of being spam. It may take a few seconds to appear after a new message is\n        received.\n\n  - else\n    %ul.spamCheckList\n      %li.spamCheckList__item.spamCheckList__item--total\n        %p.spamCheckList__score{:class => @message.spam_score <= 0 ? (@message.spam_score == 0 ? 'spamCheckList__score--neutral' : 'spamCheckList__score--positive') : 'spamCheckList__score--negative'}= @message.spam_score\n        .spamCheckList__details.spamCheckList__details--total\n          Total spam score for e-mail\n      - for spam_check in @spam_checks\n        %li.spamCheckList__item\n          %p.spamCheckList__score{:class => spam_check['score'] <= 0 ? (spam_check['score'] == 0 ? 'spamCheckList__score--neutral' : 'spamCheckList__score--positive') : 'spamCheckList__score--negative'}= spam_check['score']\n          .spamCheckList__details\n            %p.spamCheckList__code= spam_check['code']\n            %p.spamCheckList__description= spam_check['description']\n\n"
  },
  {
    "path": "app/views/messages/suppressions.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Messages\"\n- page_title << \"Suppression List\"\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :messages\n= render 'header', :active_nav => :suppressions\n.pageContent.pageContent--compact\n  - if @suppressions[:records].empty?\n    .noData.noData--clean\n      %h2.noData__title No addresses on the suppression list.\n      %p.noData__text\n        When messages cannot be delivered, addresses are added to the suppression list which stops\n        future messages to the same recipient being sent through.\n  - else\n    %p.pageContent__intro.u-margin\n      When messages cannot be delivered, addresses are added to the suppression list which stops\n      future messages to the same recipient being sent through. Recipients are removed from the list after #{Postal::Config.postal.default_suppression_list_automatic_removal_days} days.\n    %ul.suppressionList\n      - for suppression in @suppressions[:records]\n        %li.suppressionList__item\n          .suppressionList__left\n            %p.suppressionList__address= link_to suppression['address'], outgoing_organization_server_messages_path(organization, @server, :query => \"to: #{suppression['address']}\")\n            %p.suppressionList__reason= suppression['reason'].capitalize\n          .suppressionList__right\n            %p.suppressionList__timestamp Added #{Time.zone.at(suppression['timestamp']).to_fs(:long)}\n            %p.suppressionList__timestamp\n              Expires #{Time.zone.at(suppression['keep_until']).to_fs(:long)}\n              - if suppression['keep_until'] < Time.now.to_f\n                %span.u-red expired\n    = render 'shared/message_db_pagination', :data => @suppressions, :name => \"suppression\"\n"
  },
  {
    "path": "app/views/organization_ip_pools/_nav.html.haml",
    "content": ".navBar.navBar--secondary\n  %ul\n    %li.navBar__item= link_to \"IP Pools\", organization_ip_pools_path(organization), :class => ['navBar__link', active_nav == :ips ? 'is-active' : '']\n    %li.navBar__item= link_to \"Rules\", organization_ip_pool_rules_path(organization), :class => ['navBar__link', active_nav == :rules ? 'is-active' : '']\n"
  },
  {
    "path": "app/views/organization_ip_pools/index.html.haml",
    "content": "- page_title << \"IPs\"\n- page_title << \"Rules\"\n\n.pageHeader\n  %h1.pageHeader__title\n    %span.pageHeader__titlePrevious\n      = @organization.name\n      &rarr;\n    Dedicated IPs\n\n= render 'organizations/nav', :active_nav => :ips\n= render 'nav', :active_nav => :ips\n\n.pageContent.pageContent--compact\n  - if current_user.admin?\n    %p.pageContent__intro.u-margin\n      Choose which IP pools this organization will have access to send mail using. Organization\n      users will be able to choose from any of the pools chosen below. Admins can override on a per\n      server basis if required.\n    = form_tag [:assignments, @organization, :ip_pools], :method => :put do\n      %ul.checkboxList.u-margin\n        - for ip_pool in IPPool.order(:name)\n          %li.checkboxList__item\n            .checkboxList__checkbox= check_box_tag \"ip_pools[]\", ip_pool.id, @organization.ip_pools.include?(ip_pool), :id => \"ip_pool_#{ip_pool.id}\"\n            .checkboxList__label\n              = label_tag \"ip_pool_#{ip_pool.id}\", ip_pool.name, :class => 'checkboxList__actualLabel'\n      %p= submit_tag \"Save IP pool assignment\", :class => 'button button--positive'\n  - else\n    - if @ip_pools.empty?\n      .noData.noData--clean\n        - if @server.nil?\n          %h2.noData__title You don't have any assigned IP addresses.\n          %p.noData__text\n            Once you've been assigned IP addresses they will appear here. You can then use them in rules and\n            for servers.\n    - else\n      .ipList\n        - for ip_pool in @ip_pools\n          .ipList__item\n            %p.ipList__name= ip_pool.name\n            %ul.ipList__addressList\n              %li.ipList__address.ipList__address--header\n                %p.ipList__ipv4 IPv4 Address\n                %p.ipList__ipv6 IPv6 Address\n                %p.ipList__hostname Hostname\n\n              - for address in ip_pool.ip_addresses\n                %li.ipList__address\n                  %p.ipList__ipv4= address.ipv4\n                  %p.ipList__ipv6= address.ipv6\n                  %p.ipList__hostname= address.hostname\n\n\n"
  },
  {
    "path": "app/views/organizations/_nav.html.haml",
    "content": ".navBar\n  %ul\n    %li.navBar__item= link_to \"Mail Servers\", organization_root_path(organization), :class => ['navBar__link', active_nav == :servers ? 'is-active' : '']\n    %li.navBar__item= link_to \"Domains\", organization_domains_path(organization), :class => ['navBar__link', active_nav == :domains ? 'is-active' : '']\n    %li.navBar__item= link_to \"Settings\", organization_settings_path(organization), :class => ['navBar__link', active_nav == :settings ? 'is-active' : '']\n    - if Postal.ip_pools?\n      %li.navBar__item= link_to \"IPs\", organization_ip_pools_path(organization), :class => ['navBar__link', active_nav == :ips ? 'is-active' : '']\n    - if current_user.admin?\n      %li.navBar__item= link_to \"Delete Organization\", organization_delete_path(organization), :class => ['navBar__link', active_nav == :delete ? 'is-active' : '']\n"
  },
  {
    "path": "app/views/organizations/delete.html.haml",
    "content": "- page_title << organization.name\n- page_title << \"Delete\"\n.pageHeader\n  %h1.pageHeader__title\n    %span.pageHeader__titlePrevious\n      = organization.name\n      &rarr;\n    Delete organization\n= render 'nav', :active_nav => :delete\n.pageContent.pageContent--compact\n  %h2.pageContent__intro.u-margin\n    If you no longer need this organization you can delete it. When you delete an organization\n    all its mail servers & data will be deleted from our systems.\n  .dangerZone\n    %p.pageContent__text.u-margin\n      To continue to delete this organization, please enter the name of the organization in the field below and press\n      continue. <b class='u-red'>There will be no other confirmations.</b>\n    = form_tag [organization, :delete], :method => :delete, :remote => true do\n      = hidden_field_tag 'return_to', params[:return_to]\n      %p.u-margin\n        = text_field_tag \"confirm_text\", '', :class => 'input input--text input--danger'\n      .buttonSet.u-center\n        = submit_tag \"Delete this organization, mail servers and all messages\", :class => 'button button--danger js-form-submit'\n"
  },
  {
    "path": "app/views/organizations/edit.html.haml",
    "content": "- page_title << @organization.name\n- page_title << \"Organization Settings\"\n.pageHeader\n  %h1.pageHeader__title\n    %span.pageHeader__titlePrevious\n      = @organization.name\n      &rarr;\n    Settings\n\n= render 'nav', :active_nav => :settings\n\n.pageContent.pageContent--compact\n  = form_for @organization_obj, :url => organization_settings_path(@organization_obj), :remote => true do |f|\n    = f.error_messages\n    %fieldset.fieldSet\n      .fieldSet__field\n        = f.label :name, :class => 'fieldSet__label'\n        .fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'\n      .fieldSet__field\n        = f.label :time_zone, :class => 'fieldSet__label'\n        .fieldSet__input\n          = f.time_zone_select :time_zone, [], {}, :class => 'input input--select'\n          %p.fieldSet__text\n            Choose the time zone that your organization exists within. This is used when displaying times in places\n            where there isn't a logged in user to provide their own time zone.\n\n    %p.fieldSetSubmit.buttonSet\n      = f.submit \"Save Settings\", :class => 'button button--positive js-form-submit'\n"
  },
  {
    "path": "app/views/organizations/index.html.haml",
    "content": "- page_title << \"Welcome\"\n\n.pageHeader\n  %h1.pageHeader__title Welcome to Postal, #{current_user.first_name}\n\n.pageContent.pageContent--compact\n\n  - if @organizations.empty?\n    .noData.noData--clean\n      %p.noData__title There are no organizations.\n      - if current_user.admin?\n        %p.noData__text\n          You need an organization otherwise you can't do much here. Hit\n          the button below to create the first organization.\n        %p.noData__button= link_to \"Create the first organization\", :new_organization, :class => 'button button--positive'\n      - else\n        %p.noData__text\n          You don't have access to any organizations yet. Ask your administrator to invite\n          you to some organizations.\n  - else\n    %p.pageContent__intro.u-margin\n      Organizations are entities which are able to deploy mail servers.\n      Choose an existing organization from the list opposite or use the button below\n      to create a new one.\n\n    %ul.largeList.u-margin\n      - for organization in @organizations\n        %li.largeList__item\n          = link_to organization_root_path(organization), :class => 'largeList__link' do\n            = organization.name\n\n    - if current_user.admin?\n      %p.u-center= link_to \"Start another organization\", :new_organization, :class => 'button button--positive'\n"
  },
  {
    "path": "app/views/organizations/new.html.haml",
    "content": "- page_title << \"Create a new organization\"\n.pageHeader\n  %h1.pageHeader__title Create a new organization\n.pageContent.pageContent--compact\n  %p.pageContent__intro.u-margin\n    If you're starting a new organization you can do so by completing this form. You'll be able\n    to invite new users & create mail servers as soon as it has been created.\n  = form_for @organization, :remote => true do |f|\n    = f.error_messages\n    %fieldset.fieldSet\n      .fieldSet__field\n        = f.label :name, :class => 'fieldSet__label'\n        .fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'\n      .fieldSet__field\n        = f.label :permalink, :class => 'fieldSet__label'\n        .fieldSet__input\n          = f.text_field :permalink, :class => 'input input--text', :placeholder => \"Automatically generated\"\n          %p.fieldSet__text\n            This is a short name which is used in usernames and the API to identify your organization.\n            It should only contain letters, numbers & hyphens.\n\n    .fieldSetSubmit.buttonSet\n      = f.submit \"Create organization\", :class => 'button button--positive js-form-submit'\n      .fieldSetSubmit__delete\n        = link_to \"Back to homepage\", root_path, :class => 'button button--neutral'\n"
  },
  {
    "path": "app/views/routes/_form.html.haml",
    "content": "= form_for [organization, @server, @route], :remote => true do |f|\n  = f.error_messages\n  %fieldset.fieldSet\n    .fieldSet__field\n      = f.label :name, :class => 'fieldSet__label'\n      .fieldSet__input\n        .routeNameInput\n          = f.text_field :name, :autofocus => true, :class => 'input input--text routeNameInput__name'\n          %span.routeNameInput__at @\n          = f.select :domain_id, domain_options_for_select(@server, @route.domain), {}, :class => 'input input--select routeNameInput__domain'\n        %p.fieldSet__text\n          Enter the address you wish to route. In addition to the name you enter, you'll also received \"tagged\" mail for this\n          address. See our documentation for details about tagged mail.\n    .fieldSet__field\n      = f.label :_endpoint, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :_endpoint, endpoint_options_for_select(@server, @route._endpoint), {}, :class => 'input input--select'\n        %p.fieldSet__text\n          This is the endpoint where mail to this address will be delivered to. If you need to add different endpoints,\n          you can do this using the links above this form.\n\n    .fieldSet__field\n      = f.label :_endpoint, \"Additional Endpoints\", :class => 'fieldSet__label'\n      .fieldSet__input\n        .fieldSet__selectList\n          - for endpoint in @route.additional_route_endpoints_array\n            = select_tag \"route[additional_route_endpoints_array][]\", endpoint_options_for_select(@server, endpoint, :other => false), :class => 'input input--select'\n          = select_tag \"route[additional_route_endpoints_array][]\", endpoint_options_for_select(@server, nil, :other => false), :class => 'input input--select'\n\n        %p.fieldSet__text\n          If you wish to deliver a message to multiple endpoints, you can do so by choosing them from the list above.\n\n    .fieldSet__field\n      = f.label :spam_mode, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :spam_mode, Route::SPAM_MODES, {}, :class => 'input input--select'\n        %p.fieldSet__text\n          You can choose what should happen to mail which we identify as spam. If you choose <b>Mark</b> we'll tell you\n          we think its spam when we deliver it to your endpoint. If you choose <b>Quarantine</b>, we won't send the message\n          to you at all and you'll have manually accept it through our web interface or the API if you want it delivered.\n          If you choose <b>Fail</b>, the message will simply be failed without any attempt to deliver your message.\n    - if @route.persisted?\n      .fieldSet__field\n        = f.label :forward_address, \"Address\", :class => 'fieldSet__label'\n        .fieldSet__input\n          = f.text_field :forward_address, :class => 'input input--text', :readonly => true\n          %p.fieldSet__text\n            If you don't wish to point your MX records to our server, you can redirect your mail to this address and\n            will be routed to your endpoint as if it was sent to the address you entered above.\n\n  .fieldSetSubmit.buttonSet\n    = f.submit @route.new_record? ? \"Create route\" : \"Save route\", :class => 'button button--positive js-form-submit'\n    .fieldSetSubmit__delete\n      - if f.object.persisted?\n        = link_to \"Delete route\", [organization, @server, @route], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => \"Are you sure you wish to delete this route?\"}\n\n"
  },
  {
    "path": "app/views/routes/_header.html.haml",
    "content": ".navBar.navBar--secondary\n  %ul\n    %li.navBar__item= link_to \"Routes\", [organization, @server, :routes], :class => ['navBar__link', active_nav == :routes ? 'is-active' : '']\n    %li.navBar__item= link_to \"HTTP Endpoints\", [organization, @server, :http_endpoints], :class => ['navBar__link', active_nav == :http_endpoints ? 'is-active' : '']\n    %li.navBar__item= link_to \"SMTP Endpoints\", [organization, @server, :smtp_endpoints], :class => ['navBar__link', active_nav == :smtp_endpoints ? 'is-active' : '']\n    %li.navBar__item= link_to \"Address Endpoints\", [organization, @server, :address_endpoints], :class => ['navBar__link', active_nav == :address_endpoints ? 'is-active' : '']\n"
  },
  {
    "path": "app/views/routes/edit.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Routes\"\n- page_title << \"Edit Route\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :routing\n= render 'header', :active_nav => :routes\n\n.pageContent.pageContent--compact\n  = render 'form'\n"
  },
  {
    "path": "app/views/routes/index.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Routes\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :routing\n= render 'header', :active_nav => :routes\n\n.pageContent.pageContent--compact\n  - if @routes.empty?\n    .noData.noData--clean\n      %h2.noData__title No routes have been configured for this server.\n      %p.noData__text\n        To receive incoming mail, you need to add routes to where we should send\n        messages we receive for your domain. You can send incoming e-mail to\n        HTTP endpoints, other SMTP servers or e-mail addresses.\n\n        - if @server.smtp_endpoints.empty? && @server.http_endpoints.empty? && @server.address_endpoints.empty?\n          %p.noData__button.buttonSet.buttonSet--center\n            = link_to \"Add a HTTP endpoint\", new_organization_server_http_endpoint_path(organization, @server, :return_to => new_organization_server_route_path(organization, @server), :return_notice => \"You can now go ahead and add your first route for this HTTP endpoint\"), :class => 'button button--positive'\n            = link_to \"Add a SMTP endpoint\", new_organization_server_smtp_endpoint_path(organization, @server, :return_to => new_organization_server_route_path(organization, @server), :return_notice => \"You can now go ahead and add your first route for this SMTP endpoint\"), :class => 'button button--positive'\n            = link_to \"Add an address endpoint\", new_organization_server_address_endpoint_path(organization, @server, :return_to => new_organization_server_route_path(organization, @server), :return_notice => \"You can now go ahead and add your first route for this address endpoint\"), :class => 'button button--positive'\n          %p.noData__postButtonText\n            Once you've added these, you'll be able to come back here to route a\n            specific e-mail address to your newly created endpoint. You can\n            #{link_to \"add a route without an endpoint\", new_organization_server_route_path(organization, @server), :class => \"u-link\"} if you really want.\n        - else\n          %p.noData__button\n            = link_to \"Add your first route\", [:new, organization, @server, :route], :class => 'button button--positive'\n  - else\n    %p.pageContent__intro.u-margin\n      Routes control where incoming mail for your domain is sent. Messages can be sent to\n      HTTP endpoints, other SMTP servers or e-mail addresses.\n    %p.u-margin.pageContent__helpLink= link_to \"Read more about receiving e-mails\", [organization, @server, :help_incoming]\n\n    %ul.routeList.u-margin\n      - for route in @routes\n        %li.routeList__item\n          = link_to [:edit, organization, @server, route], :class => 'routeList__link' do\n            %p.routeList__name= route.description\n            .routeList__details\n              %p.routeList__endpoint{:class => \"routeList__endpoint--#{route.endpoint_type&.underscore || 'none'}\"}\n                - if route.mode == 'Endpoint'\n                  = route.endpoint.description\n                - else\n                  = t(\"route_modes.#{route.mode.underscore}\")\n              %p.routeList__spamMode= t(\"route_spam_modes.#{route.spam_mode.underscore}\")\n\n    %p.u-center= link_to \"Add another route\", [:new, organization, @server, :route], :class => 'button button--positive'\n"
  },
  {
    "path": "app/views/routes/new.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Routes\"\n- page_title << \"Add Route\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :routing\n= render 'header', :active_nav => :routes\n\n.pageContent.pageContent--compact\n  = render 'form'\n"
  },
  {
    "path": "app/views/servers/_form.html.haml",
    "content": "= form_for [organization, @server], :remote => true do |f|\n  = f.error_messages\n  %fieldset.fieldSet\n    .fieldSet__field\n      = f.label :name, :class => 'fieldSet__label'\n      .fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'\n    .fieldSet__field\n      = f.label :permalink, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.text_field :permalink, :class => 'input input--text', :placeholder => \"Automatically generated\", :disabled => @server.persisted?\n        %p.fieldSet__text\n          This is a short name which is used in usernames and the API to identify your organization.\n          It should only contain letters, numbers & hyphens.\n    .fieldSet__field\n      = f.label :mode, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :mode, Server::MODES, {}, :autofocus => true, :class => 'input input--select'\n        %p.fieldSet__text\n          The mode you choose will determine how messages are handled. When in <b>Live</b> mode, all\n          e-mail will be routed normally to the intended recipients. When in <b>Development</b> mode,\n          outgoing & incoming mail will be held and only visible in the web interface and will not be\n          sent to any recipients or HTTP endpoints.\n\n    - if Postal.ip_pools?\n      .fieldSet__field\n        = f.label :ip_pool_id, :class => 'fieldSet__label'\n        .fieldSet__input\n          = f.collection_select :ip_pool_id, organization.ip_pools.includes(:ip_addresses).order(\"`default` desc, name asc\"), :id, :name, {}, :class => 'input input--select'\n          %p.fieldSet__text\n            This is the set of IP addresses which outbound e-mails will be delivered from.\n\n    - if @server.persisted?\n      .fieldSet__field\n        = f.label :allow_sender, \"Send as any\", :class => 'fieldSet__label'\n        .fieldSet__input\n          .input.is-disabled= @server.allow_sender? ? \"Enabled\" : \"Disabled\"\n          %p.fieldSet__text\n            When enabled, you will be able to use any e-mail address in the <code>From</code> header on outgoing e-mails.\n            You will need to add a <code>Sender</code> header which must be an address at one of your verified domains.\n\n      .fieldSet__field\n        = f.label :postmaster_address, \"Postmaster\", :class => 'fieldSet__label'\n        .fieldSet__input\n          = f.text_field :postmaster_address, :class => 'input input--text', :placeholder => \"Set based on the domain\"\n          %p.fieldSet__text\n            This is the e-mail address that is included in any bounce messages that are sent when incoming\n            messages cannot be delivered. By default, the address is <code>postmaster@[yourdomain.com]</code>.\n\n  .fieldSetSubmit.buttonSet\n    = f.submit f.object.new_record? ? \"Build server\" : \"Save server\", :class => 'button button--positive js-form-submit'\n    .fieldSetSubmit__delete\n      - unless f.object.persisted?\n        = link_to \"Back to server list\", organization_root_path(organization), :class => 'button button--neutral'\n\n"
  },
  {
    "path": "app/views/servers/_header.html.haml",
    "content": ".serverHeader\n  .serverHeader__stripe{:class => \"serverHeader__stripe--#{@server.status.underscore}\"}= @server.status\n\n  .serverHeader__info\n    %p.serverHeader__title= @server.name\n\n    %ul.serverHeader__list\n      - total, unverified, bad_dns = @server.domain_stats\n      - if total == 0\n        %li No domains have been added for this server\n      - elsif bad_dns == 0\n        %li.serverHeader__list--ok DKIM & SPF configured correctly on #{pluralize total - unverified, 'domain'}\n      - else\n        %li.serverHeader__list--warning= link_to \"#{pluralize bad_dns, 'domain'} has misconfigured DNS records\", [organization, @server, :domains]\n      - if unverified > 0\n        %li= link_to \"#{pluralize unverified, 'domain'} is awaiting verification\", [organization, @server, :domains]\n      - if Postal.ip_pools? && @server.ip_pool\n        %li Sending via #{@server.ip_pool.name}\n\n  .serverHeader__stats{\"data-turbolinks-permanent\" => true, :id => \"serverStats-#{@server.uuid}\"}\n    %ul.serverHeader__statsList\n      %li.serverHeader__stat-held\n        = link_to \"#{pluralize @server.held_messages, 'message'} held\", held_organization_server_messages_path(organization, @server), :class => 'js-held-count'\n      %li.serverHeader__stat-queue\n        = link_to pluralize(@server.queue_size, 'queued message'), queue_organization_server_path(organization, @server), :class => \"js-queue-size\"\n      %li.serverHeader__stat-bounces\n        = link_to \"#{number_to_percentage @server.bounce_rate, :precision => 1} bounce rate\", outgoing_organization_server_messages_path(organization, @server, :query => \"status: hardfail status:bounced\"), :class => 'js-bounce-rate'\n      %li.serverHeader__stat-size\n        = link_to \"#{number_to_human_size @server.message_db.total_size} used\", [:retention, organization, @server], :class => 'js-disk-size'\n\n  .serverHeader__usage{\"data-turbolinks-permanent\" => true, :id => \"serverUsage-#{@server.uuid}\"}\n    %p.serverHeader__usageTitle Message throughput &mdash; last 60 minutes\n\n    .serverHeader__usageLine\n      .serverHeader__usageLineLabel Outgoing messages\n      .serverHeader__usageLineBar\n        .bar\n          .bar__inner.js-outgoing-bar{:style => style_width(@server.throughput_stats[:outgoing_usage], :color => true)}\n      .serverHeader__usageLineValue.js-outgoing-count{:title => \"Limit: #{@server.send_limit || '∞'} every 60 minutes\"}\n        = number_with_delimiter @server.throughput_stats[:outgoing]\n    .serverHeader__usageLine\n      .serverHeader__usageLineLabel Incoming messages\n      .serverHeader__usageLineValue.js-incoming-count\n        = number_with_delimiter @server.throughput_stats[:incoming]\n\n    .serverHeader__usageLine\n      .serverHeader__usageLineLabel Message Rate\n      .serverHeader__usageLineValueLarge\n        %b.js-message-rate= number_with_precision @server.message_rate, :precision => 2\n        messages/minute\n\n.navBar\n  %ul\n    %li.navBar__item= link_to \"Overview\", [organization, @server], :class => ['navBar__link', active_nav == :overview ? 'is-active' : '']\n    %li.navBar__item= link_to \"Messages\", [:outgoing, organization, @server, :messages], :class => ['navBar__link', active_nav == :messages ? 'is-active' : '']\n    %li.navBar__item= link_to \"Domains\", [organization, @server, :domains], :class => ['navBar__link', active_nav == :domains ? 'is-active' : '']\n    %li.navBar__item= link_to \"Routing\", [organization, @server, :routes], :class => ['navBar__link', active_nav == :routing ? 'is-active' : '']\n    %li.navBar__item= link_to \"Credentials\", [organization, @server, :credentials], :class => ['navBar__link', active_nav == :credentials ? 'is-active' : '']\n    %li.navBar__item= link_to \"Webhooks\", [organization, @server, :webhooks], :class => ['navBar__link', active_nav == :webhooks ? 'is-active' : '']\n    %li.navBar__item= link_to \"Settings\", [:edit, organization, @server], :class => ['navBar__link', active_nav == :settings ? 'is-active' : '']\n    %li.navBar__item.navBar__item--end= link_to \"Help\", [organization, @server, :help_outgoing], :class => ['navBar__link', active_nav == :help ? 'is-active' : '']\n"
  },
  {
    "path": "app/views/servers/_settings_header.html.haml",
    "content": ".navBar.navBar--secondary\n  %ul\n    %li.navBar__item= link_to \"Server Settings\", [:edit, organization, @server], :class => ['navBar__link', active_nav == :settings ? 'is-active' : '']\n    %li.navBar__item= link_to \"Spam\", [:spam, organization, @server], :class => ['navBar__link', active_nav == :spam ? 'is-active' : '']\n    %li.navBar__item= link_to \"Retention\", [:retention, organization, @server], :class => ['navBar__link', active_nav == :retention ? 'is-active' : '']\n    %li.navBar__item= link_to \"Send Limit\", [:limits, organization, @server], :class => ['navBar__link', active_nav == :limits ? 'is-active' : '']\n    - if Postal.ip_pools?\n      %li.navBar__item= link_to \"IP Rules\", [organization, @server, :ip_pool_rules], :class => ['navBar__link', active_nav == :ip_pool_rules ? 'is-active' : '']\n    - if current_user.admin?\n      %li.navBar__item= link_to \"Advanced Settings\", [:advanced, organization, @server], :class => ['navBar__link', active_nav == :admin ? 'is-active' : '']\n    %li.navBar__item= link_to \"Delete\", [:delete, organization, @server], :class => ['navBar__link', active_nav == :delete ? 'is-active' : '']\n"
  },
  {
    "path": "app/views/servers/_sidebar.html.haml",
    "content": "- servers = organization.servers.present.order(:name).to_a\n\n= content_for :sidebar do\n  .js-searchable\n    = form_tag '', :class => 'sidebar__search js-searchable__input' do\n      = text_field_tag 'query', '', :class => 'sidebar__searchInput js-focus-on-s', :placeholder => \"Filter servers...\"\n    %p.sidebar__placeholder.js-searchable__empty{:class => (\"is-hidden\" if servers.any?)}\n      No servers found.\n    %ul.sidebarServerList.js-searchable__list{:class => (\"is-hidden\" if servers.empty?)}\n      - for server in servers\n        %li.sidebarServerList__item.js-searchable__item{:data => {:url => organization_server_path(organization, server), :value => server.name.downcase.gsub(/\\W/, '')}}\n          = link_to [organization, server], :class => ['sidebarServerList__link', (active_server == server ? 'is-active' : '')] do\n            %p.sidebarServerList__mode.label{:class => \"label--serverStatus-#{server.status.underscore}\"}= t(\"server_statuses.#{server.status.underscore}\")\n            %p.sidebarServerList__title= server.name\n            %p.sidebarServerList__quantity #{number_with_precision server.message_rate, :precision => 2} messages/minute\n      %p.sidebar__new= link_to \"Build a new mail server\", [:new, organization, :server]\n"
  },
  {
    "path": "app/views/servers/advanced.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Settings\"\n- page_title << \"Advanced\"\n= render 'sidebar', :active_server => @server\n= render 'header', :active_nav => :settings\n= render 'settings_header', :active_nav => :admin\n.pageContent.pageContent--compact\n  .u-margin\n    = form_for [organization, @server], :remote => true do |f|\n      = f.error_messages\n      %fieldset.fieldSet.fieldSet--wide\n        .fieldSet__field\n          = f.label :send_limit, :class => 'fieldSet__label'\n          .fieldSet__input\n            = f.text_field :send_limit, :class => 'input input--text', :placeholder => \"No limit\"\n            %p.fieldSet__text This is the maximum number of e-mails that can be sent through this mail server in a 60 minute period.\n        .fieldSet__field\n          = f.label :allow_sender, \"Allow sender header\", :class => 'fieldSet__label'\n          .fieldSet__input\n            = f.select :allow_sender, [[\"No\", false], [\"Yes - can use Sender header\", true]], {}, :class => 'input input--select'\n            %p.fieldSet__text If enabled, outgoing messages can use any address in the From header as long as a Sender header is included with an authorized address.\n        .fieldSet__field\n          = f.label :privacy_mode, \"Privacy mode\", :class => 'fieldSet__label'\n          .fieldSet__input\n            = f.select :privacy_mode, [[\"Disabled\", false], [\"Enabled\", true]], {}, :class => 'input input--select'\n            %p.fieldSet__text If enabled, when Postal adds Received headers to e-mails it will not include IP or hostname information of the client submitting the message.\n        .fieldSet__field\n          = f.label :log_smtp_data, \"Log SMTP data?\", :class => 'fieldSet__label'\n          .fieldSet__input\n            = f.select :log_smtp_data, [[\"No\", false], [\"Yes - log all SMTP DATA (debug only)\", true]], {}, :class => 'input input--select'\n            %p.fieldSet__text\n              By default, no information after the DATA command in an SMTP command is logged. If enabled, all this data will be logged too. This should only\n              be used for debugging.\n        .fieldSet__field\n          = f.label :outbound_spam_threshold, :class => 'fieldSet__label'\n          .fieldSet__input\n            = f.text_field :outbound_spam_threshold, :class => 'input input--text', :placeholder => \"No outbound spam checking\"\n            %p.fieldSet__text\n              By default, outgoing messages aren't scanned for spam. You can specify a threshold here and outgoing messages that exceed this will\n              not be permitted to be sent through the mail server.\n        .fieldSet__field\n          = f.label :message_retention_days, :class => 'fieldSet__label'\n          .fieldSet__input\n            = f.text_field :message_retention_days, :class => 'input input--text'\n            %p.fieldSet__text\n              The number of days that message meta data is stored in the database after it has been added.\n        .fieldSet__field\n          = f.label :raw_message_retention_days, :class => 'fieldSet__label'\n          .fieldSet__input\n            = f.text_field :raw_message_retention_days, :class => 'input input--text'\n            %p.fieldSet__text\n              The number of days that raw message data (bodies & attachments) are stored in the database after it has been added.\n        .fieldSet__field\n          = f.label :raw_message_retention_size, :class => 'fieldSet__label'\n          .fieldSet__input\n            = f.text_field :raw_message_retention_size, :class => 'input input--text'\n            %p.fieldSet__text\n              The total amount of disk space (in megabytes) to allow raw message data to use on the disk. Older messages will be deleted to keep\n              the total usage below this amount.\n\n      .fieldSetSubmit.fieldSetSubmit--wide.buttonSet\n        = f.submit \"Save server\", :class => 'button button--positive js-form-submit'\n\n  - if @server.suspended_at\n    = form_tag [:unsuspend, organization, @server], :remote => true do\n      .fieldSetSubmit.fieldSetSubmit--wide.buttonSet\n        = submit_tag \"Unsuspend server\", :class => 'button button--danger js-form-submit'\n\n  - else\n    = form_tag [:suspend, organization, @server], :remote => true do\n      %fieldset.fieldSet.fieldSet--wide\n        .fieldSet__field\n          = label_tag :reason, 'Suspension Reason', :class => 'fieldSet__label'\n          .fieldSet__input\n            = text_field_tag :reason, '', :class => 'input input--text', :required => true\n            %p.fieldSet__text\n              If you wish to disable this server and stop it sending messages, enter a reason above. Any users assigned to the\n              server will be notified of the suspension by e-mail.\n\n      .fieldSetSubmit.fieldSetSubmit--wide.buttonSet\n        = submit_tag \"Suspend server\", :class => 'button button--positive js-form-submit'\n"
  },
  {
    "path": "app/views/servers/delete.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Delete Server\"\n= render 'sidebar', :active_server => @server\n= render 'header', :active_nav => :settings\n= render 'settings_header', :active_nav => :delete\n.pageContent.pageContent--compact\n  %h2.pageContent__intro.u-margin\n    If you no longer need this server you can remove it. When you remove a server all\n    retained messages will be deleted and all mail which is received will be rejected\n    immediately.\n  .dangerZone\n    %p.pageContent__text.u-margin\n      To continue to remove this server, please enter the server name in the field below and press\n      continue. <b class='u-red'>There will be no other confirmations.</b>\n    = form_tag [organization, @server], :remote => true, :method => :delete do\n      = hidden_field_tag 'return_to', params[:return_to]\n      %p.u-margin\n        = text_field_tag \"confirm_text\", '', :class => 'input input--text input--danger'\n      .buttonSet.u-center\n        = submit_tag \"Delete this mail server and all messages\", :class => 'button button--danger'\n"
  },
  {
    "path": "app/views/servers/edit.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Settings\"\n= render 'sidebar', :active_server => @server\n= render 'header', :active_nav => :settings\n= render 'settings_header', :active_nav => :settings\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/servers/index.html.haml",
    "content": "- page_title << \"Choose mail server\"\n.pageHeader\n  %h1.pageHeader__title\n    %span.pageHeader__titlePrevious\n      = @organization.name\n      &rarr;\n    Mail Servers\n= render 'organizations/nav', :active_nav => :servers\n.pageContent.pageContent--compact\n\n  - if @servers.empty?\n    .noData.noData--clean\n      %p.noData__title There are no mail servers for this organization yet.\n      %p.noData__text\n        Great - you've got an organization, now you need to provision a mail server.\n        Once you've got a mail server, you can start sending & receiving messages.\n      %p.noData__button.buttonSet.buttonSet--center\n        = link_to \"Build your first mail server\", [:new, organization, :server], :class => 'button button--positive'\n  - else\n    .js-searchable\n      %p.messageSearch= text_field_tag 'query', params[:query], :class => 'messageSearch__input js-searchable__input js-focus-on-s', :placeholder => \"Find a server...\"\n\n      %ul.largeList.u-margin.js-searchable__list\n        - for server in @servers\n          %li.largeList__item.js-searchable__item{:data => {:value => server.name.downcase.gsub(/\\W/, ''), :url => url_for([organization, server])}}\n            = link_to [organization, server], :class => 'largeList__link' do\n              %span.largeList__rightLabel.label{:class => \"label--serverStatus-#{server.status.underscore}\"}= t(\"server_statuses.#{server.status.underscore}\")\n              %p= server.name\n              %p.largeList__subText #{number_with_precision server.message_rate, :precision => 2} messages/minute\n      .js-searchable__empty.is-hidden\n        .noData.noData--clean\n          %p.noData__title No servers were found...\n          %p.noData__text\n            There were no servers found matching what you've typed it.\n    %p.u-center= link_to \"Build a new mail server\", [:new, organization, :server], :class => 'button button--positive'\n"
  },
  {
    "path": "app/views/servers/limits.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Limits\"\n= render 'sidebar', :active_server => @server\n= render 'header', :active_nav => :settings\n= render 'settings_header', :active_nav => :limits\n.pageContent.pageContent--compact\n\n  %p.pageContent__intro.u-margin\n    In order to protect our reputation and ensure the resiliency of our service,\n    we implement limits on the amount of e-mail that can pass through your mail\n    server.\n  %p.pageContent__text.u-margin\n    The main limit to be aware of is the amount of e-mail that you can send\n    from your mail server to external recipients in a rolling 60 minute window.\n    The current limit is shown below.\n\n  %ul.limits.u-margin\n    %li.limits__limit\n      %p.limits__value\n        - if @server.send_limit\n          = number_with_delimiter @server.send_limit\n        - else\n          unlimited\n      %p.limits__frequency e-mails every 60 minutes*\n\n\n  %p.pageContent__text\n    You can view your current usage & limit on the top of right of every mail server\n    page in the web interface. The bars will show you how close you are to reaching the\n    limits. Although we show your incoming mail throughput, it is not limited at present.\n\n  %p.pageContent__subTitle What happens if I reach the limit?\n  %p.pageContent__text\n    If you reach your outgoing limit, any new e-mails that you try to send will be held and\n    will need to be released manually when your usage has dropped.\n\n  %p.pageContent__text\n    You will be notified by e-mail (and with a webhook if enabled) when you are approaching\n    and/or exceeding your limits.\n"
  },
  {
    "path": "app/views/servers/new.html.haml",
    "content": "- page_title << \"Build new mail server\"\n.pageHeader\n  %h1.pageHeader__title\n    %span.pageHeader__titlePrevious\n      = @organization.name\n      &rarr;\n    Build a new mail server\n= render 'organizations/nav', :active_nav => :servers\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/servers/queue.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Messages\"\n- page_title << \"Queue\"\n= render 'sidebar', :active_server => @server\n= render 'header', :active_nav => :messages\n= render 'messages/header', :active_nav => :queue\n\n- if @messages.empty?\n  .pageContent--compact\n    .noData.noData--clean\n      %h2.noData__title Your queue is currently empty.\n      %p.noData__text\n        Messages which haven't yet been delivered successfully will appear in your queue until\n        we've delivered them or we've given up trying.\n- else\n  .pageContent\n    %p.pageContent__intro.u-margin\n      All messages that pass through your mail server first enter this queue. Any messages\n      that cannot be delivered immediately remain in the queue until they can be successfully\n      delivered or we give up on them.\n    = render 'messages/list', :messages => @messages_with_message\n\n    = paginate @messages\n"
  },
  {
    "path": "app/views/servers/retention.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Message Retention\"\n= render 'sidebar', :active_server => @server\n= render 'header', :active_nav => :settings\n= render 'settings_header', :active_nav => :retention\n.pageContent.pageContent--compact\n\n  %p.pageContent__intro.u-margin\n    The length of time that messages are stored by us are shown below. If you need\n    to store messages for longer, please contact us and we can work out a custom\n    plan.\n\n  .retentionLimits\n    %dl.retentionLimits__limit\n      .retentionLimits__label Number of days that raw message data will be stored\n      .retentionLimits__info\n        .retentionLimits__value\n          - if @server.raw_message_retention_days\n            = pluralize @server.raw_message_retention_days, 'day'\n          - else\n            Indefinitely\n        .retentionLimits__text\n          This is the number of whole days that raw message content will be stored by us.\n          Raw message is the actual content of the message including headers & attachments.\n    %dl.retentionLimits__limit\n      .retentionLimits__label Volume of raw message data that will be stored\n      .retentionLimits__info\n        .retentionLimits__value\n          - if @server.raw_message_retention_size\n            = number_to_human_size @server.raw_message_retention_size * 1024 * 1024\n          - else\n            No limit\n        .retentionLimits__text\n          This is the amount of e-mail that can be stored. When you exceed this amount, messages will be removed in\n          whole day increments starting with the oldest stored day.\n\n    %dl.retentionLimits__limit\n      .retentionLimits__label Number of days of message meta data will be available\n      .retentionLimits__info\n        .retentionLimits__value\n          - if @server.message_retention_days\n            = pluralize @server.message_retention_days, 'day'\n          - else\n            Indefinitely\n        .retentionLimits__text\n          This is the number of days of messages that will be available through the web interface.\n          You will be able to view basic meta information & delivery details but raw data might not\n          be available unless it is within the retention periods above.\n"
  },
  {
    "path": "app/views/servers/show.html.haml",
    "content": "- page_title << @server.name\n= render 'sidebar', :active_server => @server\n= render 'header', :active_nav => :overview\n\n- if @messages.empty?\n  .pageContent--compact\n    .noData.noData--clean\n      %h2.noData__title Your new mail server is ready to go.\n      %p.noData__text\n        Check out the information below to get started sending & receiving e-mail through your new mail server.\n      %p.noData__button.buttonSet.buttonSet--center\n        = link_to \"Read about sending e-mail\", [organization, @server, :help_outgoing], :class => \"button\"\n        = link_to \"Read about receiving e-mail\", [organization, @server, :help_incoming], :class => \"button\"\n- else\n  .pageContent\n    - if @server.suspended?\n      .suspensionBox.u-margin\n        %p\n          This server has been suspended and is not permitted to send or receive e-mail.\n          If you have any questions about this please contact our support team for assistance.\n          Please be aware that suspended servers will be fully deleted from our system 30 days after\n          suspension.\n        - if @server.actual_suspension_reason\n          %p.suspensionBox__reason\n            <b>Reason:</b> #{@server.actual_suspension_reason}\n\n    .mailGraph.u-margin{:data => {:data => @graph_data.to_json}}\n      %ul.mailGraph__key\n        %li.mailGraph__key--in Incoming Messages\n        %li.mailGraph__key--out Outgoing Messages\n\n      .mailGraph__graph\n      %ul.mailGraph__labels\n        - if @graph_type == :hourly\n          %li #{@first_date.strftime(\"%A at %l%P\")} &rarr;\n          %li Today at #{Time.now.strftime(\"%l%P\")}\n        - else\n          %li #{@first_date.to_date.to_fs(:long)} &rarr;\n          %li Today\n\n    .titleWithLinks.u-margin\n      %h2.titleWithLinks__title Recently processed e-mails\n      %ul.titleWithLinks__links\n        %li= link_to \"View message queue\", [:queue, organization, @server], :class => 'titleWithLinks__link'\n        %li= link_to \"View full e-mail history\", [:outgoing, organization, @server, :messages], :class => 'titleWithLinks__link'\n    = render 'messages/list', :messages => @messages\n"
  },
  {
    "path": "app/views/servers/spam.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Spam Handling\"\n= render 'sidebar', :active_server => @server\n= render 'header', :active_nav => :settings\n= render 'settings_header', :active_nav => :spam\n.pageContent.pageContent--compact\n  %p.pageContent__intro.u-margin\n    Postal inspects all incoming messages for spam and other threats. Incoming messages\n    are assigned a score which represents how likely an e-mail is to be spam. From here\n    you can choose at which level you'd like to identify messages as spam.\n\n  = form_for [organization, @server], :remote => true do |f|\n    .u-margin\n      %p.pageContent__subTitle Incoming Spam Threshold\n      %p.pageContent__text.u-margin\n        The main spam threshold is what determines whether a message is spam or not. How incoming\n        messages that are detected as spam are processed is determined by the route which the incoming\n        message was sent to. You can choose between marking the message as spam and sending it on to\n        your endpoint, putting it into quarantine (holding it until manually released) or just failing it.\n      %p= f.text_field :spam_threshold, :type => :range, :class => 'spamRange', :min => -10, :max => 25, :step => 0.5, :data => {:update => \"js-spam-threshold-text\"}\n      %p.spamRangeLabel Threshold is currently <b class='js-spam-threshold-text'>#{@server.spam_threshold}</b>\n\n    .u-margin\n      %p.pageContent__subTitle Incoming Spam Failure Threshold\n      %p.pageContent__text.u-margin\n        Any messages which are over your spam failure threshold will fail immediately. This is used\n        to catch messages that we are very sure are spam to avoid needlessly sending them around the place.\n      %p= f.text_field :spam_failure_threshold, :type => :range, :class => 'spamRange spamRange--hot', :min => 10, :max => 50, :step => 0.5, :data => {:update => \"js-spam-failure-threshold-text\"}\n      %p.spamRangeLabel Threshold is currently <b class='js-spam-failure-threshold-text'>#{@server.spam_failure_threshold}</b>\n\n    - if @server.outbound_spam_threshold\n      .u-margin\n        %p.pageContent__subTitle Outgoing Spam Threshold\n        %p.pageContent__text.u-margin\n          To prevent abuse of our services, we check outgoing messages to see whether they're likely to be\n          caught as spam by other providers. Messages that score higher than the threshold set by us will\n          not be passed through. If this limit needs adjusting, contact us for assistance.\n          %b The threshold for this server is currently #{@server.outbound_spam_threshold}.\n\n    %p= f.submit \"Save Spam Thresholds\", :class => \"button button--positive js-form-submit\"\n"
  },
  {
    "path": "app/views/sessions/begin_password_reset.html.haml",
    "content": "- page_title << \"Reset your password\"\n.subPageBox__title\n  Reset your password\n= display_flash\n.subPageBox__content\n  %p.subPageBox__text\n    If you've forgotten your password, just enter your e-mail address below and we'll send you an email with a link which\n    will allow you to choose a new password.\n  = form_tag login_reset_path, :class => 'loginForm' do\n    = hidden_field_tag 'return_to', params[:return_to]\n    %p.loginForm__input= text_field_tag 'email_address', '', :class => 'input input--text input--onWhite', :placeholder => \"Your e-mail address\", :autofocus => true, :tabindex => 1\n    .loginForm__submit\n      %ul.loginForm__links\n        %li= link_to \"Back to login\", login_path(:return_to => params[:return_to])\n      %p= submit_tag \"Continue\", :class => 'button button--positive', :tabindex => 3\n\n"
  },
  {
    "path": "app/views/sessions/finish_password_reset.html.haml",
    "content": "- page_title << \"Reset your password\"\n.subPageBox__title\n  Choose a new password\n= display_flash\n\n.subPageBox__content\n  %p.subPageBox__text\n    If you've forgotten your password, just enter your e-mail address below and we'll send you an email with a link which\n    will allow you to choose a new password.\n  = form_tag '', :class => 'loginForm' do\n    = error_messages_for @user\n    = hidden_field_tag 'return_to', params[:return_to]\n    %p.loginForm__input= password_field_tag 'password', params[:password], :class => 'input input--text input--onWhite', :placeholder => \"Choose a new password\", :autofocus => true, :tabindex => 1\n    %p.loginForm__input= password_field_tag 'password_confirmation', params[:password_confirmation], :class => 'input input--text input--onWhite', :placeholder => \"and enter it again to confirm\", :tabindex => 2\n\n    .loginForm__submit\n      %ul.loginForm__links\n        %li= link_to \"Back to login\", login_path(:return_to => params[:return_to])\n      %p= submit_tag \"Login\", :class => 'button button--positive', :tabindex => 3\n\n"
  },
  {
    "path": "app/views/sessions/new.html.haml",
    "content": "- page_title << \"Login\"\n.subPageBox__title\n  Welcome to Postal\n= display_flash\n\n.subPageBox__content\n  = form_tag login_path, :class => 'loginForm' do\n    = hidden_field_tag 'return_to', params[:return_to]\n\n    - if Postal::Config.oidc.enabled?\n      .loginForm__oidcButton\n        = link_to \"Login with #{Postal::Config.oidc.name}\", \"/auth/oidc\", method: :post, class: 'button button--full'\n\n    - if Postal::Config.oidc.enabled? && Postal::Config.oidc.local_authentication_enabled?\n      .loginForm__divider\n      %p.loginForm__localTitle or login with a local user\n\n    - if Postal::Config.oidc.local_authentication_enabled?\n      %p.loginForm__input= text_field_tag 'email_address', '', :type => 'email', :spellcheck => 'false', :class => 'input input--text input--onWhite', :placeholder => \"Your e-mail address\", :autofocus => !Postal::Config.oidc.enabled?, :tabindex => 1\n      %p.loginForm__input= password_field_tag 'password', '', :class => 'input input--text input--onWhite', :placeholder => \"Your password\", :tabindex => 2\n      .loginForm__submit\n        %ul.loginForm__links\n          %li= link_to \"Forgotten your password?\", login_reset_path(:return_to => params[:return_to])\n        %p= submit_tag \"Login\", :class => 'button button--positive', :tabindex => 3\n"
  },
  {
    "path": "app/views/shared/_message_db_pagination.html.haml",
    "content": ".simplePagination\n  %p.simplePagination__previous\n    - if data[:page] > 1\n      = link_to \"&larr; Previous page\".html_safe, request.params.merge(:page => data[:page] - 1), :class => 'simplePagination__link'\n  .simplePagination__current\n    %p.simplePagination__info Showing #{number_with_delimiter data[:records].size} of #{number_with_delimiter data[:total]} #{data[:total] == 1 ? name : name.pluralize}\n    %p Page #{data[:page]} of #{number_with_delimiter data[:total_pages]}\n  %p.simplePagination__next\n    - if data[:total_pages] > data[:page]\n      = link_to \"Next page &rarr;\".html_safe, request.params.merge(:page => data[:page] + 1), :class => 'simplePagination__link'\n"
  },
  {
    "path": "app/views/smtp_endpoints/_form.html.haml",
    "content": "= form_for [organization, @server, @smtp_endpoint], :remote => true do |f|\n  = f.error_messages\n  %fieldset.fieldSet\n    .fieldSet__field\n      = f.label :name, :class => 'fieldSet__label'\n      .fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'\n    .fieldSet__field\n      = f.label :hostname, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.text_field :hostname, :class => 'input input--text'\n    .fieldSet__field\n      = f.label :port, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.text_field :port, :class => 'input input--text', :placeholder => \"25 (by default)\"\n    .fieldSet__field\n      = f.label :ssl_mode, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :ssl_mode, SMTPEndpoint::SSL_MODES, {}, :class => 'input input--select'\n        %p.fieldSet__text\n          Choose what, if any, SSL mode you'd like to use when delivering mail to this mail server.\n          Be aware that any mail sent with no SSL is insecure and not protected in anyway.\n\n  .fieldSetSubmit.buttonSet\n    = f.submit @smtp_endpoint.new_record? ? \"Create SMTP endpoint\" : \"Save SMTP endpoint\", :class => 'button button--positive js-form-submit'\n    .fieldSetSubmit__delete\n      - if f.object.persisted?\n        = link_to \"Delete SMTP endpoint\", [organization, @server, @smtp_endpoint], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => \"Are you sure you wish to delete this SMTP endpoint?\\n\\r#{pluralize @smtp_endpoint.routes.size, 'route'} that uses this endpoint will also be deleted.\"}\n\n  = hidden_field_tag 'return_to', params[:return_to]\n  = hidden_field_tag 'return_notice', params[:return_notice]\n"
  },
  {
    "path": "app/views/smtp_endpoints/edit.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Routing\"\n- page_title << \"SMTP Endpoints\"\n- page_title << \"Edit\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :routing\n= render 'routes/header', :active_nav => :smtp_endpoints\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/smtp_endpoints/index.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Routing\"\n- page_title << \"SMTP Endpoints\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :routing\n= render 'routes/header', :active_nav => :smtp_endpoints\n.pageContent.pageContent--compact\n\n  - if @smtp_endpoints.empty?\n    .noData.noData--clean\n      %h2.noData__title There are no SMTP endpoints for this server.\n      %p.noData__text\n        SMTP endpoints are other mail servers that you'd like incoming e-mails\n        to be passed onto. Once you've added some endpoints, you can route messages\n        to them by creating #{link_to 'routes', [organization, @server, :routes], :class => 'u-link'}.\n      %p.noData__button\n        = link_to \"Add your first SMTP endpoint\", [:new, organization, @server, :smtp_endpoint], :class => 'button button--positive'\n\n  - else\n\n    %ul.endpointList.u-margin\n      - for endpoint in @smtp_endpoints\n        %li.endpointList__item\n          = link_to [:edit, organization, @server, endpoint], :class => 'endpointList__link' do\n            .endpointList__main\n              %p.endpointList__name= endpoint.name\n              %p.endpointList__url= endpoint.hostname\n            %ul.endpointList__details\n              %li.endpointList__detailItem\n                - if endpoint.last_used_at\n                  Last used #{distance_of_time_in_words_to_now endpoint.last_used_at} ago\n                - else\n                  Not used yet\n\n    %p.u-center= link_to \"Add another SMTP endpoint\", [:new, organization, @server, :smtp_endpoint], :class => 'button button--positive'\n"
  },
  {
    "path": "app/views/smtp_endpoints/new.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Routing\"\n- page_title << \"SMTP Endpoints\"\n- page_title << \"New\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :routing\n= render 'routes/header', :active_nav => :smtp_endpoints\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/track_domains/_form.html.haml",
    "content": "= form_for [organization, @server, @track_domain], :remote => true do |f|\n  = f.error_messages\n  %fieldset.fieldSet\n    .fieldSet__field\n      = f.label :name, \"Domain\", :class => 'fieldSet__label'\n      .fieldSet__input\n        .routeNameInput\n          = f.text_field :name, :autofocus => true, :class => 'input input--text routeNameInput__name', :disabled => @track_domain.persisted?\n          %span.routeNameInput__at .\n          = f.select :domain_id, domain_options_for_select(@server, @track_domain.domain), {}, :class => 'input input--select routeNameInput__domain', :disabled => @track_domain.persisted?\n        %p.fieldSet__text\n          This is the domain that requests for tracked links will be directed through when you use click tracking. We recommend using something like\n          <b>click.yourdomain.com</b>. Once chosen, add a CNAME record which points to <b>#{Postal::Config.dns.track_domain}</b>.\n\n    .fieldSet__field\n      = f.label :ssl_enabled, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :ssl_enabled, [[\"Yes - use SSL for tracking whenever possible\", true], [\"No - never use SSL for tracking\", false]], {}, :class => 'input input--select'\n        %p.fieldSet__text\n          If enabled, we'll use https for the tracking domain when replacing links and images. Please note that a SSL certificate \n          should be installed on the tracking domain if enabled. \n\n    .fieldSet__field\n      = f.label :track_loads, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :track_loads, [[\"Yes - track when HTML e-mails are opened\", true], [\"No - don't track when HTML e-mails are opened\", false]], {}, :class => 'input input--select'\n        %p.fieldSet__text\n          If enabled, we'll insert a 1px image into the footer of any HTML e-mails. When this image is loaded, we'll log\n          this as a view and notify you with a webhook.\n\n    .fieldSet__field\n      = f.label :track_clicks, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :track_clicks, [[\"Yes - track when links are clicked\", true], [\"No - don't track when links are clicked\", false]], {}, :class => 'input input--select'\n        %p.fieldSet__text\n          If enabled, we'll rewrite URLs in your outbound messages to go via this domain. You'll receive a webhook when\n          someone clicks one of your links and it will be displayed in the web interface.\n\n    .fieldSet__field\n      = f.label :excluded_click_domains, \"Domains excluded from tracking\", :class => 'fieldSet__label'\n      .fieldSet__input\n        ~ f.text_area :excluded_click_domains, :class => 'input input--smallArea'\n        %p.fieldSet__text\n          This is a list of domains of links that you don't wish to be tracked. When click tracking is enabled,\n          you can provide a list (one domain per line) for links that you don't wish to be tracked.\n\n\n  .fieldSetSubmit.buttonSet\n    = f.submit @track_domain.new_record? ? \"Create Track Domain\" : \"Save Track Domain\", :class => 'button button--positive js-form-submit'\n"
  },
  {
    "path": "app/views/track_domains/edit.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Tracking Domains\"\n- page_title << \"Edit Tracking Domain Setting\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :domains\n= render 'domains/nav', :active_nav => :track_domains\n.pageContent.pageContent--compact\n  = render 'form'\n"
  },
  {
    "path": "app/views/track_domains/index.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Tracking Domains\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :domains\n= render 'domains/nav', :active_nav => :track_domains\n\n.pageContent.pageContent--compact\n\n  - if @track_domains.empty?\n    .noData.noData--clean\n      %h2.noData__title There are no tracking domains for this server.\n      %p.noData__text\n        To use Postal's open & click tracking, you need to configure a domain that links will be re-written to use. Enable\n        message tracking by adding a suitable tracking domain for your outbound e-mails.\n      %p.noData__button= link_to \"Add a custom tracking domain\", [:new, organization, @server, :track_domain], :class => \"button button--positive\"\n\n  - else\n    %ul.domainList.u-margin\n      - for track_domain in @track_domains\n        %li.domainList__item\n          .domainList__details\n            %p.domainList__name\n              = link_to track_domain.full_name, [:edit, organization, @server, track_domain]\n            %ul.domainList__checks\n              - if track_domain.dns_status == 'OK'\n                %li.domainList__check.domainList__check--ok CNAME configured correctly\n              - elsif track_domain.dns_status.nil?\n                %li.domainList__check.domainList__check--neutral-cross CNAME/DNS not checked yet\n              - else\n                %li.domainList__check.domainList__check--warning{:title => track_domain.dns_error} CNAME not configured correctly\n\n              - if track_domain.ssl_enabled?\n                %li.domainList__check.domainList__check--neutral= link_to \"SSL enabled\", [:toggle_ssl, organization, @server, track_domain], :remote => true, :method => :post\n              - else\n                %li.domainList__check.domainList__check--neutral-cross= link_to \"SSL disabled\", [:toggle_ssl, organization, @server, track_domain], :remote => true, :method => :post\n\n          %ul.domainList__properties\n            %li.domainList__links\n              = link_to \"Settings\", [:edit, organization, @server, track_domain]\n              = link_to \"Check DNS\", [:check, organization, @server, track_domain], :remote => true, :method => :post, :data => {:disable_with => \"Checking...\"}\n              = link_to \"Delete\", [organization, @server, track_domain], :remote => true, :method => :delete, :data => {:confirm => \"Are you sure you wish to remove this domain?\", :disable_with => \"Deleting...\"}, :class => 'domainList__delete'\n\n    %p.u-center= link_to \"Add new track domain\", [:new, organization, @server, :track_domain], :class => \"button button--positive\"\n"
  },
  {
    "path": "app/views/track_domains/new.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Tracking Domains\"\n- page_title << \"New Tracking Domain\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :domains\n= render 'domains/nav', :active_nav => :track_domains\n.pageContent.pageContent--compact\n  = render 'form'\n"
  },
  {
    "path": "app/views/user/edit.html.haml",
    "content": "- page_title << \"My Settings\"\n.pageHeader\n  %h1.pageHeader__title\n    My Settings\n.pageContent.pageContent--compact\n  = form_for @user, :url => settings_path, :remote => true do |f|\n    = f.error_messages\n    %fieldset.fieldSet\n      - if @user.password? && Postal::Config.oidc.local_authentication_enabled?\n        .fieldSet__field\n          = label_tag :password, 'Your Password', :class => 'fieldSet__label'\n          .fieldSet__input\n            = password_field_tag :password, params[:password], :autofocus => @password_correct.nil?, :disabled => @password_correct, :class => 'input input--text', :placeholder => \"Enter your current password to change your details\"\n            - if @password_correct\n              = hidden_field_tag :password, params[:password]\n            %p.fieldSet__text\n              In order to protect your account, you need to enter your current password in the field above\n              to authenticate the change of your details.\n\n      .fieldSet__title\n        Your details\n\n      .fieldSet__field\n        = f.label :first_name, \"Name\", :class => 'fieldSet__label'\n        .fieldSet__input\n          .inputPair\n            = f.text_field :first_name, :class => 'input input--text', :autofocus => @password_correct\n            = f.text_field :last_name, :class => 'input input--text'\n      .fieldSet__field\n        = f.label :email_address, :class => 'fieldSet__label'\n        .fieldSet__input\n          = f.text_field :email_address, :class => 'input input--text'\n          %p.fieldSet__text\n            If you change your e-mail address, you'll need to verify that you own the new one before\n            you can continue using your account.\n\n      .fieldSet__field\n        = f.label :time_zone, :class => 'fieldSet__label'\n        .fieldSet__input\n          = f.time_zone_select :time_zone, [], {}, :class => 'input input--select'\n          %p.fieldSet__text\n            Choose the time zone that you'd like times to be displayed to you when you use our\n            web interface. By default, times are displayed in UTC.\n\n      - if @user.password? && Postal::Config.oidc.local_authentication_enabled?\n        .fieldSet__title\n          Change your password?\n        .fieldSet__field\n          = f.label :password, \"New Password\", :class => 'fieldSet__label'\n          .fieldSet__input\n            .inputPair\n              = f.password_field :password, :class => 'input input--text', :placeholder => \"•••••••••••\", :value => @user.password\n              = f.password_field :password_confirmation, :class => 'input input--text', :placeholder => \"and confirm it\", :value => @user.password_confirmation\n\n\n    %p.fieldSetSubmit.buttonSet\n      = f.submit \"Save Settings\", :class => 'button button--positive js-form-submit'\n"
  },
  {
    "path": "app/views/users/_form.html.haml",
    "content": "= form_for @user, :url => @user.new_record? ? users_path : user_path(@user), :remote => true do |f|\n  = f.error_messages\n  .fieldSet\n    .fieldSet__title\n      Enter user details\n    .fieldSet__field\n      = f.label :first_name, :class => 'fieldSet__label'\n      .fieldSet__input= f.text_field :first_name, :class => 'input input--text', :autofocus => true\n    .fieldSet__field\n      = f.label :last_name, :class => 'fieldSet__label'\n      .fieldSet__input= f.text_field :last_name, :class => 'input input--text'\n    .fieldSet__field\n      = f.label :email_address, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.text_field :email_address, :class => 'input input--text', autocomplete: 'one-time-code'\n        - if Postal::Config.oidc.enabled?\n          %p.fieldSet__text\n            This e-mail address should match the address provided by your OpenID Connect identity provider.\n\n    \n    - if Postal::Config.oidc.local_authentication_enabled? && !@user.persisted?\n      .fieldSet__field\n        = f.label :password, :class => 'fieldSet__label'\n        .fieldSet__input\n          = f.password_field :password, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'\n          - if Postal::Config.oidc.enabled?\n            %p.fieldSet__text\n              You have enabled OIDC which means a password is not required. If you do not provide\n              a password this user will be matched to an OIDC identity based on the e-mail address\n              provided above. You may, however, enter a password and this user will be permitted to\n              use that password until they have successfully logged in with OIDC.\n        \n      .fieldSet__field\n        = f.label :password_confirmation, \"Confirm\".html_safe, :class => 'fieldSet__label'\n        .fieldSet__input= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'\n\n  %fieldset.fieldSet\n    .fieldSet__title\n      What level of access do you wish to grant?\n    .fieldSet__titleSubText\n      Admin users have full access to all organizations and settings. Non-admin users will only\n      have access to the organizations that you select here.\n    .fieldSet__field\n      .fieldSet__label Admin?\n      .fieldSet__input\n        = hidden_field_tag 'user[organization_ids][]'\n        = f.select :admin, [[\"Yes - grant admin access\", true], [\"No - do not grant admin access\", false]], {},:class => 'input input--select fieldSet__checkboxListAfter js-checkbox-list-toggle'\n        %ul.checkboxList{:class => [@user.admin? ? 'is-hidden' : '']}\n          - for org in Organization.order(:name).to_a\n            %li.checkboxList__item\n              .checkboxList__checkbox= check_box_tag \"user[organization_ids][]\", org.id, @user.organizations.include?(org), :id => \"org_#{org.id}\"\n              .checkboxList__label\n                = label_tag \"org_#{org.id}\", org.name, :class => 'checkboxList__actualLabel'\n\n  .fieldSetSubmit.buttonSet\n    = submit_tag @user.new_record? ? \"Add User\" : \"Save User\", :class => 'button button--positive js-form-submit'\n    .fieldSetSubmit__delete\n      = link_to \"Back to user list\", :users, :class => 'button button--neutral'\n"
  },
  {
    "path": "app/views/users/edit.html.haml",
    "content": "- page_title << \"Users\"\n- page_title << \"Permissions\"\n.pageHeader\n  %h1.pageHeader__title\n    %span.pageHeader__titlePrevious\n      = link_to 'Users', users_path\n      &rarr;\n    Edit user permissions\n\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/users/index.html.haml",
    "content": "- page_title << \"Users\"\n.pageHeader\n  %h1.pageHeader__title\n    Users\n\n.pageContent.pageContent--compact\n  %ul.userList.u-margin\n    - for user in @users\n      %li.userList__item\n        .userList__details\n          %p.userList__name\n            = user.name\n            - if user.admin?\n              %span.userList__tag.label.label--blue Admin\n            - if Postal::Config.oidc.enabled?\n              - if user.oidc?\n                %span.userList__tag.label.label--green OIDC\n              - elsif !Postal::Config.oidc.local_authentication_enabled?\n                %span.userList__tag.label.label--orange Pending\n              \n          %p.userList__email= user.email_address\n        %ul.userList__actions\n          %li= link_to \"Edit user\", [:edit, user]\n          %li= link_to \"Delete user\", user, :method => :delete, :data => {:confirm => \"Are you sure you wish to revoke #{user.name}'s access?\", :disable_with => \"Deleting...\"}, :remote => true, :class => 'userList__revoke'\n\n  %p.u-center= link_to \"Add a new user\", :new_user, :class => 'button button--positive'\n"
  },
  {
    "path": "app/views/users/new.html.haml",
    "content": "- page_title << \"Users\"\n- page_title << \"Add\"\n.pageHeader\n  %h1.pageHeader__title\n    %span.pageHeader__titlePrevious\n      = link_to 'Users', :users\n      &rarr;\n    Add user\n\n.pageContent.pageContent--compact\n  %p.pageContent__intro.u-margin\n    To add someone to this Postal installation, you can add them below. You'll need to\n    choose whether to make them an admin (full access to all organizations and settings)\n    or whether you wish to limit them to specific organizations.\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/webhooks/_form.html.haml",
    "content": "= form_for [organization, @server, @webhook], :remote => true do |f|\n  = f.error_messages\n  %fieldset.fieldSet\n    .fieldSet__field\n      = f.label :name, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.text_field :name, :autofocus => true, :class => 'input input--text'\n        %p.fieldSet__text\n          Enter a name to describe this webhook. This is used so you can identify this webhook later in the web interface.\n\n    .fieldSet__field\n      = f.label :url, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.text_field :url, :class => 'input input--text'\n        %p.fieldSet__text\n          Enter the URL that you'd like us to send requests to. All requests will be POST requests with\n          a JSON-encoded payload in the body of the request.\n    .fieldSet__field\n      = f.label :enabled, :class => 'fieldSet__label'\n      .fieldSet__input\n        = f.select :enabled, [[\"Yes - send requests to this webhook\", true], [\"No - do not send requests at the moment\", false]], {},:class => 'input input--select'\n        %p.fieldSet__text\n          You can enable or disable this webhook without fully removing it from the system. If there are any outstanding\n          webhook deliveries, they will still be completed even if disabled.\n\n    .fieldSet__field\n      = f.label :all_events, 'Events', :class => 'fieldSet__label'\n      .fieldSet__input\n        = hidden_field_tag 'webhook[events][]'\n        = f.select :all_events, [[\"Yes - send all events to this URL\", true], [\"No - I'll choose which requests to send\", false]], {},:class => 'input input--select fieldSet__checkboxListAfter js-checkbox-list-toggle'\n        %ul.checkboxList{:class => [@webhook.all_events? ? 'is-hidden' : '']}\n          - for event in WebhookEvent::EVENTS\n            %li.checkboxList__item\n              .checkboxList__checkbox= check_box_tag \"webhook[events][]\", event, @webhook.events.include?(event), :id => \"event_#{event}\"\n              .checkboxList__label\n                = label_tag \"event_#{event}\", event, :class => 'checkboxList__actualLabel checkboxList__devEvent'\n                %p.checkBoxList__text= t(\"webhook_events.#{event.underscore}\")\n\n  .fieldSetSubmit.buttonSet\n    = f.submit @webhook.new_record? ? \"Create Webhook\" : \"Save Webhook\", :class => 'button button--positive js-form-submit'\n    .fieldSetSubmit__delete\n      - if f.object.persisted?\n        = link_to \"Delete Webhook\", [organization, @server, @webhook], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => \"Are you sure you wish to delete this webhook?\"}\n\n"
  },
  {
    "path": "app/views/webhooks/_header.html.haml",
    "content": ".navBar.navBar--secondary\n  %ul\n    %li.navBar__item= link_to \"Manage Webhooks\", [organization, @server, :webhooks], :class => ['navBar__link', active_nav == :webhooks ? 'is-active' : '']\n    %li.navBar__item= link_to \"View History\", [:history, organization, @server, :webhooks], :class => ['navBar__link', active_nav == :history ? 'is-active' : '']\n\n"
  },
  {
    "path": "app/views/webhooks/edit.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Webhooks\"\n- page_title << \"Edit\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :webhooks\n= render 'header', :active_nav => :webhooks\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "app/views/webhooks/history.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Webhooks\"\n- page_title << \"History\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :webhooks\n= render 'header', :active_nav => :history\n\n.pageContent.pageContent--compact\n  - if @requests[:records].empty?\n    .noData.noData--clean\n      %h2.noData__title No webhook requests recorded.\n      %p.noData__text\n        This page shows the last 10 days worth of webhook requests that have been sent by Postal. This page will\n        populate automatically as webhooks are dispatched.\n\n  - else\n    %p.pageContent__intro.u-margin\n      This page shows a list of all webhook requests which have been sent for this server. These are kept for 10 days before being\n      removed. Click on a request for additional information.\n    %ul.webhookRequestList\n      - for req in @requests[:records]\n        %li.webhookRequestList__item\n          = link_to history_request_organization_server_webhooks_path(organization, @server, req.uuid), :class => 'webhookRequestList__link' do\n            .webhookRequestList__top\n              %p.webhookRequestList__status\n                %span.label{:class => \"label--http-status-#{req.status_code.to_s[0,1]}\"}= req.status_code\n              %p.webhookRequestList__time= req.timestamp.strftime(\"%d/%m/%Y at %H:%M:%S\")\n              %p.webhookRequestList__event= req.event\n            %p.webhookRequestList__url= req.url || \"Unknown\"\n\n    = render 'shared/message_db_pagination', :data => @requests, :name => 'request'\n"
  },
  {
    "path": "app/views/webhooks/history_request.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Webhooks\"\n- page_title << \"History\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :webhooks\n= render 'header', :active_nav => :history\n\n.pageContent.pageContent--compact\n  %dl.pageContent__definitions.u-margin\n    %dt URL\n    %dd= @req.url\n    %dt Event\n    %dd= @req.event\n    %dt UUID\n    %dd= @req.uuid\n\n    %dt Timestamp\n    %dd= @req.timestamp.strftime(\"%d/%m/%Y at %H:%M:%S\")\n    %dt HTTP Status Code\n    %dd\n      %span.label.label--large{:class => \"label--http-status-#{@req.status_code.to_s[0,1]}\"}= @req.status_code\n\n    %dt Attempt\n    %dd\n      = @req.attempt\n      - if @req.will_retry?\n        (will be retried)\n\n  %p.pageContent__title Payload\n  %pre.codeBlock.u-margin.codeBlock--whitespace~ preserve @req.pretty_payload\n  %p.pageContent__title Response Body\n  %pre.codeBlock.u-margin= @req.body\n  %p.u-margin= link_to \"Back to history\", [:history, organization, @server, :webhooks], :class => \"button button--neutral\"\n"
  },
  {
    "path": "app/views/webhooks/index.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Webhooks\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :webhooks\n= render 'header', :active_nav => :webhooks\n.pageContent.pageContent--compact\n  - if @webhooks.empty?\n    .noData.noData--clean\n      %h2.noData__title No webhooks have been configured for this server.\n      %p.noData__text\n        You haven't added any webhooks for this server yet. A webhook enables your web\n        application to be notified when certain events occur in the lifecycle of the mail server.\n      %p.noData__button= link_to \"Add your first webhook\", [:new, organization, @server, :webhook], :class => \"button button--positive\"\n  - else\n    %ul.webhookList.u-margin\n      - for webhook in @webhooks\n        %li.webhookList__item\n          .webhookList__top\n            %p.webhookList__name= link_to webhook.name, [:edit, organization, @server, webhook]\n            %p.webhookList__labels\n              - if webhook.enabled?\n                %span.label.label--green Enabled\n              - else\n                %span.label.label--red Disabled\n          .webhookList__bottom\n            %p.webhookList__usageTime\n              - if webhook.last_used_at\n                Last sent request #{distance_of_time_in_words_to_now webhook.last_used_at}.\n              - else\n                Not used yet.\n            %ul.webhookList__links\n              %li.webhookList__link= link_to \"Edit Webhook\", [:edit, organization, @server, webhook]\n\n    %p.u-center= link_to \"Add another webhook\", [:new, organization, @server, :webhook], :class => 'button button--positive'\n"
  },
  {
    "path": "app/views/webhooks/new.html.haml",
    "content": "- page_title << @server.name\n- page_title << \"Webhooks\"\n- page_title << \"New\"\n\n= render 'servers/sidebar', :active_server => @server\n= render 'servers/header', :active_nav => :webhooks\n= render 'header', :active_nav => :webhooks\n.pageContent.pageContent--compact\n  = render 'form'\n\n"
  },
  {
    "path": "bin/bundle",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\n#\n# This file was generated by Bundler.\n#\n# The application 'bundle' is installed as part of a gem, and\n# this file is here to facilitate running it.\n#\n\nrequire \"rubygems\"\n\nm = Module.new do\n  module_function\n\n  def invoked_as_script?\n    File.expand_path($0) == File.expand_path(__FILE__)\n  end\n\n  def env_var_version\n    ENV[\"BUNDLER_VERSION\"]\n  end\n\n  def cli_arg_version\n    return unless invoked_as_script? # don't want to hijack other binstubs\n    return unless \"update\".start_with?(ARGV.first || \" \") # must be running `bundle update`\n    bundler_version = nil\n    update_index = nil\n    ARGV.each_with_index do |a, i|\n      if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN\n        bundler_version = a\n      end\n      next unless a =~ /\\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\\z/\n      bundler_version = $1\n      update_index = i\n    end\n    bundler_version\n  end\n\n  def gemfile\n    gemfile = ENV[\"BUNDLE_GEMFILE\"]\n    return gemfile if gemfile && !gemfile.empty?\n\n    File.expand_path(\"../Gemfile\", __dir__)\n  end\n\n  def lockfile\n    lockfile =\n      case File.basename(gemfile)\n      when \"gems.rb\" then gemfile.sub(/\\.rb$/, \".locked\")\n      else \"#{gemfile}.lock\"\n      end\n    File.expand_path(lockfile)\n  end\n\n  def lockfile_version\n    return unless File.file?(lockfile)\n    lockfile_contents = File.read(lockfile)\n    return unless lockfile_contents =~ /\\n\\nBUNDLED WITH\\n\\s{2,}(#{Gem::Version::VERSION_PATTERN})\\n/\n    Regexp.last_match(1)\n  end\n\n  def bundler_requirement\n    @bundler_requirement ||=\n      env_var_version ||\n      cli_arg_version ||\n      bundler_requirement_for(lockfile_version)\n  end\n\n  def bundler_requirement_for(version)\n    return \"#{Gem::Requirement.default}.a\" unless version\n\n    bundler_gem_version = Gem::Version.new(version)\n\n    bundler_gem_version.approximate_recommendation\n  end\n\n  def load_bundler!\n    ENV[\"BUNDLE_GEMFILE\"] ||= gemfile\n\n    activate_bundler\n  end\n\n  def activate_bundler\n    gem_error = activation_error_handling do\n      gem \"bundler\", bundler_requirement\n    end\n    return if gem_error.nil?\n    require_error = activation_error_handling do\n      require \"bundler/version\"\n    end\n    return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))\n    warn \"Activating bundler (#{bundler_requirement}) failed:\\n#{gem_error.message}\\n\\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`\"\n    exit 42\n  end\n\n  def activation_error_handling\n    yield\n    nil\n  rescue StandardError, LoadError => e\n    e\n  end\nend\n\nm.load_bundler!\n\nif m.invoked_as_script?\n  load Gem.bin_path(\"bundler\", \"bundle\")\nend\n"
  },
  {
    "path": "bin/dev",
    "content": "#!/usr/bin/env bash\n\nif ! command -v foreman &> /dev/null\nthen\n  echo \"Installing foreman...\"\n  gem install foreman\nfi\n\nforeman start -f Procfile.dev\n"
  },
  {
    "path": "bin/postal",
    "content": "#!/bin/bash\nROOT_DIR=$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )/..\" && pwd )\nset -e\n\nrun() {\n    eval $@\n}\n\n# Enter the root directory\ncd $ROOT_DIR\n\n# Run the right command\ncase \"$1\" in\n    web-server)\n        run \"bundle exec puma -C config/puma.rb\"\n        ;;\n\n    smtp-server)\n        run \"bundle exec ruby script/smtp_server.rb\"\n        ;;\n\n    worker)\n        run \"bundle exec ruby script/worker.rb\"\n        ;;\n\n    initialize)\n        echo 'Initializing database'\n        run \"bundle exec rake db:create postal:update\"\n        ;;\n\n    upgrade)\n        run \"bundle exec rake postal:update\"\n        ;;\n\n    update)\n        run \"bundle exec rake postal:update\"\n        ;;\n\n    console)\n        run \"bundle exec rails console\"\n        ;;\n\n    default-dkim-record)\n        run \"bundle exec ruby script/default_dkim_record.rb\"\n        ;;\n\n    make-user)\n        run \"bundle exec ruby script/make_user.rb\"\n        ;;\n\n    test-app-smtp)\n        run \"bundle exec ruby script/test_app_smtp.rb $2\"\n        ;;\n\n    version)\n        run \"bundle exec ruby script/version.rb\"\n        ;;\n\n    *)\n        echo \"Usage: postal [command]\"\n        echo\n        echo \"Server components:\"\n        echo\n        echo -e \" * \\033[35mweb-server\\033[0m - run the web server\"\n        echo -e \" * \\033[35msmtp-server\\033[0m - run the SMTP server\"\n        echo -e \" * \\033[35mworker\\033[0m - run a worker\"\n        echo\n        echo \"Setup/upgrade tools:\"\n        echo\n        echo -e \" * \\033[32minitialize\\033[0m - create and load the DB schema\"\n        echo -e \" * \\033[32mupdate\\033[0m - upgrade the DB schema\"\n        echo\n        echo \"Other tools:\"\n        echo\n        echo -e \" * \\033[34mversion\\033[0m - show the current Postal version\"\n        echo -e \" * \\033[34mmake-user\\033[0m - create a new global admin user\"\n        echo -e \" * \\033[34mdefault-dkim-record\\033[0m - display the default DKIM record\"\n        echo -e \" * \\033[34mconsole\\033[0m - open an interactive console\"\n        echo -e \" * \\033[34mtest-app-smtp\\033[0m - send a test message through Postal\"\n        echo\nesac\n"
  },
  {
    "path": "bin/rails",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\nAPP_PATH = File.expand_path(\"../config/application\", __dir__)\nrequire_relative \"../config/boot\"\nrequire \"rails/commands\"\n"
  },
  {
    "path": "bin/rake",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\nrequire_relative \"../config/boot\"\nrequire \"rake\"\nRake.application.run\n"
  },
  {
    "path": "bin/rspec",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\n#\n# This file was generated by Bundler.\n#\n# The application 'rspec' is installed as part of a gem, and\n# this file is here to facilitate running it.\n#\n\nENV[\"BUNDLE_GEMFILE\"] ||= File.expand_path(\"../Gemfile\", __dir__)\n\nbundle_binstub = File.expand_path(\"bundle\", __dir__)\n\nif File.file?(bundle_binstub)\n  if File.read(bundle_binstub, 300).include?(\"This file was generated by Bundler\")\n    load(bundle_binstub)\n  else\n    abort(\"Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.\nReplace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.\")\n  end\nend\n\nrequire \"rubygems\"\nrequire \"bundler/setup\"\n\nload Gem.bin_path(\"rspec-core\", \"rspec\")\n"
  },
  {
    "path": "bin/setup",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\nrequire \"pathname\"\nrequire \"fileutils\"\ninclude FileUtils\n\n# path to your application root.\nAPP_ROOT = Pathname.new File.expand_path(\"..\", __dir__)\n\ndef system!(*args)\n  system(*args) || abort(\"\\n== Command #{args} failed ==\")\nend\n\nchdir APP_ROOT do\n  # This script is a starting point to setup your application.\n  # Add necessary setup steps to this file.\n\n  puts \"== Installing dependencies ==\"\n  system! \"gem install bundler --conservative\"\n  system(\"bundle check\") || system!(\"bundle install\")\n\n  # puts \"\\n== Copying sample files ==\"\n  # unless File.exist?('config/database.yml')\n  #   cp 'config/database.yml.sample', 'config/database.yml'\n  # end\n\n  puts \"\\n== Preparing database ==\"\n  system! \"bin/rails db:setup\"\n\n  puts \"\\n== Removing old logs and tempfiles ==\"\n  system! \"bin/rails log:clear tmp:clear\"\n\n  puts \"\\n== Restarting application server ==\"\n  system! \"bin/rails restart\"\nend\n"
  },
  {
    "path": "bin/update",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\nrequire \"pathname\"\nrequire \"fileutils\"\ninclude FileUtils\n\n# path to your application root.\nAPP_ROOT = Pathname.new File.expand_path(\"..\", __dir__)\n\ndef system!(*args)\n  system(*args) || abort(\"\\n== Command #{args} failed ==\")\nend\n\nchdir APP_ROOT do\n  # This script is a way to update your development environment automatically.\n  # Add necessary update steps to this file.\n\n  puts \"== Installing dependencies ==\"\n  system! \"gem install bundler --conservative\"\n  system(\"bundle check\") || system!(\"bundle install\")\n\n  puts \"\\n== Updating database ==\"\n  system! \"bin/rails db:migrate\"\n\n  puts \"\\n== Removing old logs and tempfiles ==\"\n  system! \"bin/rails log:clear tmp:clear\"\n\n  puts \"\\n== Restarting application server ==\"\n  system! \"bin/rails restart\"\nend\n"
  },
  {
    "path": "config/application.rb",
    "content": "# frozen_string_literal: true\n\nrequire_relative \"boot\"\n\nrequire \"rails\"\nrequire \"active_model/railtie\"\nrequire \"active_record/railtie\"\nrequire \"action_controller/railtie\"\nrequire \"action_mailer/railtie\"\nrequire \"action_view/railtie\"\nrequire \"sprockets/railtie\"\n\n# Require the gems listed in Gemfile, including any gems\n# you've limited to :test, :development, or :production.\ngem_groups = Rails.groups\ngem_groups << :oidc if Postal::Config.oidc.enabled?\nBundler.require(*gem_groups)\n\nmodule Postal\n  class Application < Rails::Application\n\n    config.load_defaults 7.0\n\n    # Disable most generators\n    config.generators do |g|\n      g.orm             :active_record\n      g.test_framework  false\n      g.stylesheets     false\n      g.javascripts     false\n      g.helper          false\n    end\n\n    # Include from lib\n    config.eager_load_paths << Rails.root.join(\"lib\")\n\n    # Disable field_with_errors\n    config.action_view.field_error_proc = proc { |t, _| t }\n\n    # Load the tracking server middleware\n    require \"tracking_middleware\"\n    config.middleware.insert_before ActionDispatch::HostAuthorization, TrackingMiddleware\n\n    config.hosts << Postal::Config.postal.web_hostname\n\n    unless Postal::Config.logging.rails_log_enabled?\n      config.logger = Logger.new(\"/dev/null\")\n    end\n\n  end\nend\n"
  },
  {
    "path": "config/boot.rb",
    "content": "# frozen_string_literal: true\n\nENV[\"BUNDLE_GEMFILE\"] ||= File.expand_path(\"../Gemfile\", __dir__)\n\nrequire \"bundler/setup\" # Set up gems listed in the Gemfile.\n\nrequire_relative \"../lib/postal/config\"\n\nENV[\"RAILS_ENV\"] = Postal::Config.rails.environment || \"development\"\n"
  },
  {
    "path": "config/database.yml",
    "content": "default: &default\n  adapter: mysql2\n  reconnect: true\n  encoding: \"<%= Postal::Config.main_db.encoding %>\"\n  pool: <%= Postal::Config.main_db.pool_size %>\n  username: \"<%= Postal::Config.main_db.username %>\"\n  password: \"<%= Postal::Config.main_db.password %>\"\n  host: \"<%= Postal::Config.main_db.host %>\"\n  port: <%= Postal::Config.main_db.port %>\n  database: \"<%= Postal::Config.main_db.database %>\"\n\ndevelopment:\n  <<: *default\n\nproduction:\n  <<: *default\n\ntest:\n  <<: *default\n"
  },
  {
    "path": "config/environment.rb",
    "content": "# frozen_string_literal: true\n\n# Load the Rails application.\nrequire_relative \"application\"\n\n# Initialize the Rails application.\nRails.application.initialize!\n"
  },
  {
    "path": "config/environments/development.rb",
    "content": "# frozen_string_literal: true\n\nRails.application.configure do\n  # Settings specified here will take precedence over those in config/application.rb.\n\n  # In the development environment your application's code is reloaded on\n  # every request. This slows down response time but is perfect for development\n  # since you don't have to restart the web server when you make code changes.\n  config.enable_reloading = true\n\n  # Do not eager load code on boot.\n  config.eager_load = false\n\n  # Show full error reports.\n  config.consider_all_requests_local = true\n\n  # Enable/disable caching. By default caching is disabled.\n  if Rails.root.join(\"tmp/caching-dev.txt\").exist?\n    config.action_controller.perform_caching = true\n\n    config.cache_store = :memory_store\n    config.public_file_server.headers = {\n      \"Cache-Control\" => \"public, max-age=172800\"\n    }\n  else\n    config.action_controller.perform_caching = false\n\n    config.cache_store = :null_store\n  end\n\n  # Don't care if the mailer can't send.\n  config.action_mailer.raise_delivery_errors = true\n\n  config.action_mailer.perform_caching = false\n\n  # Print deprecation notices to the Rails logger.\n  config.active_support.deprecation = :log\n\n  # Raise an error on page load if there are pending migrations.\n  config.active_record.migration_error = :page_load\n\n  # Debug mode disables concatenation and preprocessing of assets.\n  # This option may cause significant delays in view rendering with a large\n  # number of complex assets.\n  config.assets.debug = true\n\n  # Suppress logger output for asset requests.\n  config.assets.quiet = false\n\n  # Raises error for missing translations\n  # config.action_view.raise_on_missing_translations = true\n\n  # Use an evented file watcher to asynchronously detect changes in source code,\n  # routes, locales, etc. This feature depends on the listen gem.\n  # config.file_watcher = ActiveSupport::EventedFileUpdateChecker\nend\n"
  },
  {
    "path": "config/environments/production.rb",
    "content": "# frozen_string_literal: true\n\nRails.application.configure do\n  # Settings specified here will take precedence over those in config/application.rb.\n\n  # Code is not reloaded between requests.\n  config.enable_reloading = false\n\n  # Eager load code on boot. This eager loads most of Rails and\n  # your application in memory, allowing both threaded web servers\n  # and those relying on copy on write to perform better.\n  # Rake tasks automatically ignore this option for performance.\n  config.eager_load = true\n\n  # Full error reports are disabled and caching is turned on.\n  config.consider_all_requests_local       = false\n  config.action_controller.perform_caching = true\n\n  # Disable serving static files from the `/public` folder by default since\n  # Apache or NGINX already handles this.\n  config.public_file_server.enabled = true\n\n  # Compress JavaScripts and CSS.\n  config.assets.js_compressor = :uglifier\n  # config.assets.css_compressor = :sass\n\n  # Do not fallback to assets pipeline if a precompiled asset is missed.\n  config.assets.compile = false\n\n  # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb\n\n  # Enable serving of images, stylesheets, and JavaScripts from an asset server.\n  # config.action_controller.asset_host = 'http://assets.example.com'\n\n  # Specifies the header that your server uses for sending files.\n  # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache\n  # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX\n\n  # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.\n  # config.force_ssl = true\n\n  # Use the lowest log level to ensure availability of diagnostic information\n  # when problems arise.\n  config.log_level = :info\n\n  # Prepend all log lines with the following tags.\n  config.log_tags = [:request_id]\n\n  # Use a different cache store in production.\n  # config.cache_store = :mem_cache_store\n\n  # Use a real queuing backend for Active Job (and separate queues per environment)\n  # config.active_job.queue_adapter     = :resque\n  # config.active_job.queue_name_prefix = \"deliver_#{Rails.env}\"\n  config.action_mailer.perform_caching = false\n\n  # Ignore bad email addresses and do not raise email delivery errors.\n  # Set this to true and configure the email server for immediate delivery to raise delivery errors.\n  config.action_mailer.raise_delivery_errors = true\n\n  # Enable locale fallbacks for I18n (makes lookups for any locale fall back to\n  # the I18n.default_locale when a translation cannot be found).\n  config.i18n.fallbacks = true\n\n  # Send deprecation notices to registered listeners.\n  config.active_support.deprecation = :notify\n\n  # Use default logging formatter so that PID and timestamp are not suppressed.\n  config.log_formatter = Logger::Formatter.new\n\n  # Use a different logger for distributed setups.\n  # require 'syslog/logger'\n  # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name')\n\n  # Do not dump schema after migrations.\n  config.active_record.dump_schema_after_migration = false\nend\n"
  },
  {
    "path": "config/environments/test.rb",
    "content": "# frozen_string_literal: true\n\nRails.application.configure do\n  # Settings specified here will take precedence over those in config/application.rb.\n\n  # The test environment is used exclusively to run your application's\n  # test suite. You never need to work with it otherwise. Remember that\n  # your test database is \"scratch space\" for the test suite and is wiped\n  # and recreated between test runs. Don't rely on the data there!\n  config.enable_reloading = false\n\n  # Do not eager load code on boot. This avoids loading your whole application\n  # just for the purpose of running a single test. If you are using a tool that\n  # preloads Rails for running tests, you may have to set it to true.\n  config.eager_load = false\n\n  # Configure public file server for tests with Cache-Control for performance.\n  config.public_file_server.enabled = true\n  config.public_file_server.headers = {\n    \"Cache-Control\" => \"public, max-age=3600\"\n  }\n\n  # Show full error reports and disable caching.\n  config.consider_all_requests_local       = true\n  config.action_controller.perform_caching = false\n\n  # Raise exceptions instead of rendering exception templates.\n  config.action_dispatch.show_exceptions = false\n\n  # Disable request forgery protection in test environment.\n  config.action_controller.allow_forgery_protection = false\n  config.action_mailer.perform_caching = false\n\n  # Tell Action Mailer not to deliver emails to the real world.\n  # The :test delivery method accumulates sent emails in the\n  # ActionMailer::Base.deliveries array.\n  config.action_mailer.delivery_method = :test\n\n  # Print deprecation notices to the stderr.\n  config.active_support.deprecation = :stderr\n\n  # Raises error for missing translations\n  # config.action_view.raise_on_missing_translations = true\nend\n"
  },
  {
    "path": "config/examples/development.yml",
    "content": "# This is an example Postal configuration file for use in \n# development environments. For a production example, see\n# the https://github.com/postalserver/install repository.\n\nversion: 2\n\npostal:\n  web_hostname: postal.example.com\n  web_protocol: https\n  smtp_hostname: postal.example.com\n\nmain_db:\n  host: 127.0.0.1\n  username: root\n  password: \n  database: postal\n\nmessage_db:\n  host: 127.0.0.1\n  username: root\n  password: \n  prefix: postal\n\nlogging:\n  rails_log_enabled: true\n  highlighting_enabled: true\n\nrails:\n  environment: development\n  secret_key: 7f27856d26e864bafd49d0df37ad3d1339086e86ef0447e0f1814dde5277452fea97dab9e3aad6dfa11bfe359c82ce302d97bf1e58f6103c4408e4fbad4eeccf\n"
  },
  {
    "path": "config/examples/test.yml",
    "content": "# This is an example Postal configuration file for use in \n# test environments. For a production example, see\n# the https://github.com/postalserver/install repository.\n\nversion: 2\n\nmain_db:\n  host: 127.0.0.1\n  username: root\n  password: \n  database: postal-test\n\nmessage_db:\n  host: 127.0.0.1\n  username: root\n  password: \n  prefix: postal-test\n\nlogging:\n  enabled: false\n  rails_log_enabled: false\n\nrails:\n  environment: test\n  secret_key: 7f27856d26e864bafd49d0df37ad3d1339086e86ef0447e0f1814dde5277452fea97dab9e3aad6dfa11bfe359c82ce302d97bf1e58f6103c4408e4fbad4eeccf\n"
  },
  {
    "path": "config/initializers/_wait_for_migrations.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"migration_waiter\"\n\nMigrationWaiter.wait_if_appropriate\n"
  },
  {
    "path": "config/initializers/application_controller_renderer.rb",
    "content": "# frozen_string_literal: true\n# Be sure to restart your server when you modify this file.\n\n# ApplicationController.renderer.defaults.merge!(\n#   http_host: 'example.org',\n#   https: false\n# )\n"
  },
  {
    "path": "config/initializers/assets.rb",
    "content": "# frozen_string_literal: true\n\n# Be sure to restart your server when you modify this file.\n\n# Version of your assets, change this if you want to expire all your assets.\nRails.application.config.assets.version = \"1.0\"\n\n# Add additional assets to the asset load path\n# Rails.application.config.assets.paths << Emoji.images_path\n\n# Precompile additional assets.\n# application.js, application.css, and all non-JS/CSS in app/assets folder are already added.\n# Rails.application.config.assets.precompile += %w( search.js )\n"
  },
  {
    "path": "config/initializers/backtrace_silencers.rb",
    "content": "# frozen_string_literal: true\n# Be sure to restart your server when you modify this file.\n\n# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.\n# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }\n\n# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.\n# Rails.backtrace_cleaner.remove_silencers!\n"
  },
  {
    "path": "config/initializers/content_security_policy.rb",
    "content": "# frozen_string_literal: true\n# Be sure to restart your server when you modify this file.\n\n# Define an application-wide content security policy.\n# See the Securing Rails Applications Guide for more information:\n# https://guides.rubyonrails.org/security.html#content-security-policy-header\n\n# Rails.application.configure do\n#   config.content_security_policy do |policy|\n#     policy.default_src :self, :https\n#     policy.font_src    :self, :https, :data\n#     policy.img_src     :self, :https, :data\n#     policy.object_src  :none\n#     policy.script_src  :self, :https\n#     policy.style_src   :self, :https\n#     # Specify URI for violation reports\n#     # policy.report_uri \"/csp-violation-report-endpoint\"\n#   end\n#\n#   # Generate session nonces for permitted importmap and inline scripts\n#   config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }\n#   config.content_security_policy_nonce_directives = %w(script-src)\n#\n#   # Report violations without enforcing the policy.\n#   # config.content_security_policy_report_only = true\n# end\n"
  },
  {
    "path": "config/initializers/cookies_serializer.rb",
    "content": "# frozen_string_literal: true\n\n# Be sure to restart your server when you modify this file.\n\n# Specify a serializer for the signed and encrypted cookie jars.\n# Valid options are :json, :marshal, and :hybrid.\nRails.application.config.action_dispatch.cookies_serializer = :json\n"
  },
  {
    "path": "config/initializers/filter_parameter_logging.rb",
    "content": "# frozen_string_literal: true\n\n# Be sure to restart your server when you modify this file.\n\n# Configure parameters to be filtered from the log file. Use this to limit dissemination of\n# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported\n# notations and behaviors.\nRails.application.config.filter_parameters += [\n  :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn,\n]\n"
  },
  {
    "path": "config/initializers/inflections.rb",
    "content": "# frozen_string_literal: true\n\n# Be sure to restart your server when you modify this file.\n\n# Add new inflection rules using the following format. Inflections\n# are locale specific, and you may define rules for as many different\n# locales as you wish. All of these examples are active by default:\n# ActiveSupport::Inflector.inflections(:en) do |inflect|\n#   inflect.plural /^(ox)$/i, '\\1en'\n#   inflect.singular /^(ox)en/i, '\\1'\n#   inflect.irregular 'person', 'people'\n#   inflect.uncountable %w( fish sheep )\n# end\n\n# These inflection rules are supported but not enabled by default:\nActiveSupport::Inflector.inflections(:en) do |inflect|\n  inflect.acronym \"DKIM\"\n  inflect.acronym \"HTTP\"\n  inflect.acronym \"OIDC\"\n  inflect.acronym \"SMTP\"\n  inflect.acronym \"UUID\"\n\n  inflect.acronym \"API\"\n  inflect.acronym \"DNS\"\n  inflect.acronym \"SSL\"\n  inflect.acronym \"MySQL\"\n\n  inflect.acronym \"DB\"\n  inflect.acronym \"IP\"\n  inflect.acronym \"MQ\"\n  inflect.acronym \"MX\"\nend\n"
  },
  {
    "path": "config/initializers/logging.rb",
    "content": "# frozen_string_literal: true\n\nbegin\n  def add_exception_to_payload(payload, event)\n    return unless exception = event.payload[:exception_object]\n\n    payload[:exception_class] = exception.class.name\n    payload[:exception_message] = exception.message\n    payload[:exception_backtrace] = exception.backtrace[0, 4].join(\"\\n\")\n  end\n\n  ActiveSupport::Notifications.subscribe \"process_action.action_controller\" do |*args|\n    event = ActiveSupport::Notifications::Event.new(*args)\n\n    payload = {\n      event: \"request\",\n      transaction: event.transaction_id,\n      controller: event.payload[:controller],\n      action: event.payload[:action],\n      format: event.payload[:format],\n      method: event.payload[:method],\n      path: event.payload[:path],\n      request_id: event.payload[:request].request_id,\n      ip_address: event.payload[:request].ip,\n      status: event.payload[:status],\n      view_runtime: event.payload[:view_runtime],\n      db_runtime: event.payload[:db_runtime]\n    }\n\n    add_exception_to_payload(payload, event)\n\n    string = \"#{payload[:method]} #{payload[:path]} (#{payload[:status]})\"\n\n    if payload[:exception_class]\n      Postal.logger.error(string, **payload)\n    else\n      Postal.logger.info(string, **payload)\n    end\n  end\n\n  ActiveSupport::Notifications.subscribe \"deliver.action_mailer\" do |*args|\n    event = ActiveSupport::Notifications::Event.new(*args)\n\n    Postal.logger.info({\n      event: \"send_email\",\n      transaction: event.transaction_id,\n      message_id: event.payload[:message_id],\n      subject: event.payload[:subject],\n      from: event.payload[:from],\n      to: event.payload[:to].is_a?(Array) ? event.payload[:to].join(\", \") : event.payload[:to].to_s\n    })\n  end\nend\n"
  },
  {
    "path": "config/initializers/mail_extensions.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"mail\"\nmodule Mail\n\n  module Encodings\n\n    # Handle windows-1258 as windows-1252 when decoding\n    def self.q_value_decode(str)\n      str = str.sub(/=\\?windows-?1258\\?/i, '\\=?windows-1252?')\n      Utilities.q_value_decode(str)\n    end\n\n    def self.b_value_decode(str)\n      str = str.sub(/=\\?windows-?1258\\?/i, '\\=?windows-1252?')\n      Utilities.b_value_decode(str)\n    end\n\n  end\n\n  class Message\n\n    ## Extract plain text body of message\n    def plain_body\n      if multipart? && text_part\n        text_part.decoded\n      elsif mime_type == \"text/plain\" || mime_type.nil?\n        decoded\n      end\n    end\n\n    ## Extract HTML text body of message\n    def html_body\n      if multipart? && html_part\n        html_part.decoded\n      elsif mime_type == \"text/html\"\n        decoded\n      end\n    end\n\n    private\n\n    ## Fix bug in basic parsing\n    def parse_message\n      self.header, self.body = raw_source.split(/\\r?\\n\\r?\\n/m, 2)\n    end\n\n    # Handle attached emails as attachments\n    # Returns the filename of the attachment (if it exists) or returns nil\n    # Make up a filename for rfc822 attachments if it isn't specified\n    def find_attachment\n      content_type_name = begin\n        header[:content_type].filename\n      rescue StandardError\n        nil\n      end\n      content_disp_name = begin\n        header[:content_disposition].filename\n      rescue StandardError\n        nil\n      end\n      content_loc_name = begin\n        header[:content_location].location\n      rescue StandardError\n        nil\n      end\n\n      if content_type && content_type_name\n        filename = content_type_name\n      elsif content_disposition && content_disp_name\n        filename = content_disp_name\n      elsif content_location && content_loc_name\n        filename = content_loc_name\n      elsif mime_type == \"message/rfc822\"\n        filename = \"#{rand(100_000_000)}.eml\"\n      else\n        filename = nil\n      end\n\n      if filename\n        # Normal decode\n        filename = begin\n          Mail::Encodings.decode_encode(filename, :decode)\n        rescue StandardError\n          filename\n        end\n      end\n      filename\n    end\n\n    def decode_body_as_text\n      body_text = decode_body\n      charset_tmp = begin\n        Encoding.find(Utilities.pick_encoding(charset))\n      rescue StandardError\n        \"ASCII\"\n      end\n      charset_tmp = \"Windows-1252\" if charset_tmp.to_s =~ /windows-?1258/i\n      if charset_tmp == Encoding.find(\"UTF-7\")\n        body_text.force_encoding(\"UTF-8\")\n        decoded = body_text.gsub(/\\+.*?-/m) { |n| Base64.decode64(n[1..-2] + \"===\").force_encoding(\"UTF-16BE\").encode(\"UTF-8\") }\n      else\n        body_text.force_encoding(charset_tmp)\n        decoded = body_text.encode(\"utf-8\", invalid: :replace, undef: :replace)\n      end\n      decoded.valid_encoding? ? decoded : decoded.encode(\"utf-16le\", invalid: :replace, undef: :replace).encode(\"utf-8\")\n    end\n\n  end\n\n  # Handle attached emails as attachments\n  class AttachmentsList < Array\n\n    # rubocop:disable Lint/MissingSuper\n    def initialize(parts_list)\n      @parts_list = parts_list\n      @content_disposition_type = \"attachment\"\n      parts = parts_list.map do |p|\n        p.parts.empty? && p.attachment? ? p : p.attachments\n      end.flatten.compact\n      parts.each { |a| self << a }\n    end\n    # rubocop:enable Lint/MissingSuper\n\n  end\n\nend\n\nclass Array\n\n  def decoded\n    return nil if empty?\n\n    first.decoded\n  end\n\nend\n\nclass NilClass\n\n  def decoded\n    nil\n  end\n\nend\n"
  },
  {
    "path": "config/initializers/mime_types.rb",
    "content": "# frozen_string_literal: true\n# Be sure to restart your server when you modify this file.\n\n# Add new mime types for use in respond_to blocks:\n# Mime::Type.register \"text/richtext\", :rtf\n"
  },
  {
    "path": "config/initializers/new_framework_defaults_7_0.rb",
    "content": "# frozen_string_literal: true\n# Be sure to restart your server when you modify this file.\n#\n# This file eases your Rails 7.0 framework defaults upgrade.\n#\n# Uncomment each configuration one by one to switch to the new default.\n# Once your application is ready to run with all new defaults, you can remove\n# this file and set the `config.load_defaults` to `7.0`.\n#\n# Read the Guide for Upgrading Ruby on Rails for more info on each option.\n# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html\n\n# `button_to` view helper will render `<button>` element, regardless of whether\n# or not the content is passed as the first argument or as a block.\n# Rails.application.config.action_view.button_to_generates_button_tag = true\n\n# `stylesheet_link_tag` view helper will not render the media attribute by default.\n# Rails.application.config.action_view.apply_stylesheet_media_default = false\n\n# Change the digest class for the key generators to `OpenSSL::Digest::SHA256`.\n# Changing this default means invalidate all encrypted messages generated by\n# your application and, all the encrypted cookies. Only change this after you\n# rotated all the messages using the key rotator.\n#\n# See upgrading guide for more information on how to build a rotator.\n# https://guides.rubyonrails.org/v7.0/upgrading_ruby_on_rails.html\n# Rails.application.config.active_support.key_generator_hash_digest_class = OpenSSL::Digest::SHA256\n\n# Change the digest class for ActiveSupport::Digest.\n# Changing this default means that for example Etags change and\n# various cache keys leading to cache invalidation.\n# Rails.application.config.active_support.hash_digest_class = OpenSSL::Digest::SHA256\n\n# Don't override ActiveSupport::TimeWithZone.name and use the default Ruby\n# implementation.\n# Rails.application.config.active_support.remove_deprecated_time_with_zone_name = true\n\n# Calls `Rails.application.executor.wrap` around test cases.\n# This makes test cases behave closer to an actual request or job.\n# Several features that are normally disabled in test, such as Active Record query cache\n# and asynchronous queries will then be enabled.\n# Rails.application.config.active_support.executor_around_test_case = true\n\n# Set both the `:open_timeout` and `:read_timeout` values for `:smtp` delivery method.\n# Rails.application.config.action_mailer.smtp_timeout = 5\n\n# The ActiveStorage video previewer will now use scene change detection to generate\n# better preview images (rather than the previous default of using the first frame\n# of the video).\n# Rails.application.config.active_storage.video_preview_arguments =\n#   \"-vf 'select=eq(n\\\\,0)+eq(key\\\\,1)+gt(scene\\\\,0.015),loop=loop=-1:size=2,trim=start_frame=1' -frames:v 1 -f image2\"\n\n# Automatically infer `inverse_of` for associations with a scope.\n# Rails.application.config.active_record.automatic_scope_inversing = true\n\n# Raise when running tests if fixtures contained foreign key violations\n# Rails.application.config.active_record.verify_foreign_keys_for_fixtures = true\n\n# Disable partial inserts.\n# This default means that all columns will be referenced in INSERT queries\n# regardless of whether they have a default or not.\n# Rails.application.config.active_record.partial_inserts = false\n\n# Protect from open redirect attacks in `redirect_back_or_to` and `redirect_to`.\n# Rails.application.config.action_controller.raise_on_open_redirects = true\n\n# Change the variant processor for Active Storage.\n# Changing this default means updating all places in your code that\n# generate variants to use image processing macros and ruby-vips\n# operations. See the upgrading guide for detail on the changes required.\n# The `:mini_magick` option is not deprecated; it's fine to keep using it.\n# Rails.application.config.active_storage.variant_processor = :vips\n\n# Enable parameter wrapping for JSON.\n# Previously this was set in an initializer. It's fine to keep using that initializer if you've customized it.\n# To disable parameter wrapping entirely, set this config to `false`.\n# Rails.application.config.action_controller.wrap_parameters_by_default = true\n\n# Specifies whether generated namespaced UUIDs follow the RFC 4122 standard for namespace IDs provided as a\n# `String` to `Digest::UUID.uuid_v3` or `Digest::UUID.uuid_v5` method calls.\n#\n# See https://guides.rubyonrails.org/configuring.html#config-active-support-use-rfc4122-namespaced-uuids for\n# more information.\n# Rails.application.config.active_support.use_rfc4122_namespaced_uuids = true\n\n# Change the default headers to disable browsers' flawed legacy XSS protection.\n# Rails.application.config.action_dispatch.default_headers = {\n#   \"X-Frame-Options\" => \"SAMEORIGIN\",\n#   \"X-XSS-Protection\" => \"0\",\n#   \"X-Content-Type-Options\" => \"nosniff\",\n#   \"X-Download-Options\" => \"noopen\",\n#   \"X-Permitted-Cross-Domain-Policies\" => \"none\",\n#   \"Referrer-Policy\" => \"strict-origin-when-cross-origin\"\n# }\n\n# ** Please read carefully, this must be configured in config/application.rb **\n# Change the format of the cache entry.\n# Changing this default means that all new cache entries added to the cache\n# will have a different format that is not supported by Rails 6.1 applications.\n# Only change this value after your application is fully deployed to Rails 7.0\n# and you have no plans to rollback.\n# When you're ready to change format, add this to `config/application.rb` (NOT this file):\n#  config.active_support.cache_format_version = 7.0\n\n# Cookie serializer: 2 options\n#\n# If you're upgrading and haven't set `cookies_serializer` previously, your cookie serializer\n# is `:marshal`. The default for new apps is `:json`.\n#\n# Rails.application.config.action_dispatch.cookies_serializer = :json\n#\n#\n# To migrate an existing application to the `:json` serializer, use the `:hybrid` option.\n#\n# Rails transparently deserializes existing (Marshal-serialized) cookies on read and\n# re-writes them in the JSON format.\n#\n# It is fine to use `:hybrid` long term; you should do that until you're confident *all* your cookies\n# have been converted to JSON. To keep using `:hybrid` long term, move this config to its own\n# initializer or to `config/application.rb`.\n#\n# Rails.application.config.action_dispatch.cookies_serializer = :hybrid\n#\n#\n# If your cookies can't yet be serialized to JSON, keep using `:marshal` for backward-compatibility.\n#\n# If you have configured the serializer elsewhere, you can remove this section of the file.\n#\n# See https://guides.rubyonrails.org/action_controller_overview.html#cookies for more information.\n\n# Change the return value of `ActionDispatch::Request#content_type` to the Content-Type header without modification.\n# Rails.application.config.action_dispatch.return_only_request_media_type_on_content_type = false\n\n# Active Storage `has_many_attached` relationships will default to replacing the current collection instead of appending to it.\n# Thus, to support submitting an empty collection, the `file_field` helper will render an hidden field `include_hidden` by default when `multiple_file_field_include_hidden` is set to `true`.\n# See https://guides.rubyonrails.org/configuring.html#config-active-storage-multiple-file-field-include-hidden for more information.\n# Rails.application.config.active_storage.multiple_file_field_include_hidden = true\n\n# ** Please read carefully, this must be configured in config/application.rb (NOT this file) **\n# Disables the deprecated #to_s override in some Ruby core classes\n# See https://guides.rubyonrails.org/configuring.html#config-active-support-disable-to-s-conversion for more information.\n# config.active_support.disable_to_s_conversion = true\n"
  },
  {
    "path": "config/initializers/omniauth.rb",
    "content": "# frozen_string_literal: true\n\nconfig = Postal::Config.oidc\nif config.enabled?\n  client_options = { identifier: config.identifier, secret: config.secret }\n\n  client_options[:redirect_uri] = \"#{Postal::Config.postal.web_protocol}://#{Postal::Config.postal.web_hostname}/auth/oidc/callback\"\n\n  unless config.discovery?\n    client_options[:authorization_endpoint] = config.authorization_endpoint\n    client_options[:token_endpoint] = config.token_endpoint\n    client_options[:userinfo_endpoint] = config.userinfo_endpoint\n    client_options[:jwks_uri] = config.jwks_uri\n  end\n\n  Rails.application.config.middleware.use OmniAuth::Builder do\n    provider :openid_connect, name: :oidc,\n                              scope: config.scopes.map(&:to_sym),\n                              uid_field: config.uid_field,\n                              issuer: config.issuer,\n                              discovery: config.discovery?,\n                              client_options: client_options\n  end\n\n  OmniAuth.config.on_failure = proc do |env|\n    SessionsController.action(:oauth_failure).call(env)\n  end\nend\n"
  },
  {
    "path": "config/initializers/permissions_policy.rb",
    "content": "# frozen_string_literal: true\n# Define an application-wide HTTP permissions policy. For further\n# information see https://developers.google.com/web/updates/2018/06/feature-policy\n#\n# Rails.application.config.permissions_policy do |f|\n#   f.camera      :none\n#   f.gyroscope   :none\n#   f.microphone  :none\n#   f.usb         :none\n#   f.fullscreen  :self\n#   f.payment     :self, \"https://secure.example.com\"\n# end\n"
  },
  {
    "path": "config/initializers/postal.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"postal\"\n"
  },
  {
    "path": "config/initializers/record_key_for_dom.rb",
    "content": "# frozen_string_literal: true\n\nmodule ActionView\n  module RecordIdentifier\n\n    def dom_id(record, prefix = nil)\n      if record.new_record?\n        dom_class(record, prefix || NEW)\n      else\n        id = record.respond_to?(:uuid) ? record.uuid : record.id\n        \"#{dom_class(record, prefix)}#{JOIN}#{id}\"\n      end\n    end\n\n  end\nend\n"
  },
  {
    "path": "config/initializers/secret_key.rb",
    "content": "# frozen_string_literal: true\n\nif Postal::Config.rails.secret_key\n  Rails.application.credentials.secret_key_base = Postal::Config.rails.secret_key\nelse\n  warn \"No secret key was specified in the Postal config file. Using one for just this session\"\n  Rails.application.credentials.secret_key_base = SecureRandom.hex(128)\nend\n"
  },
  {
    "path": "config/initializers/secure_headers.rb",
    "content": "# frozen_string_literal: true\n\nSecureHeaders::Configuration.default do |config|\n  config.hsts = SecureHeaders::OPT_OUT\n\n  config.csp[:default_src] = []\n  config.csp[:script_src] = [\"'self'\"]\n  config.csp[:child_src] = [\"'self'\"]\n  config.csp[:connect_src] = [\"'self'\"]\nend\n"
  },
  {
    "path": "config/initializers/sentry.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"postal/config\"\n\nif Postal::Config.logging.sentry_dsn\n  Sentry.init do |config|\n    config.dsn = Postal::Config.logging.sentry_dsn\n  end\nend\n"
  },
  {
    "path": "config/initializers/session_store.rb",
    "content": "# frozen_string_literal: true\n\n# Be sure to restart your server when you modify this file.\n\nRails.application.config.session_store :cookie_store, key: \"_postal_session\"\n"
  },
  {
    "path": "config/initializers/smtp.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"postal/config\"\n\nconfig = Postal::Config.smtp\n\nActionMailer::Base.delivery_method = :smtp\nActionMailer::Base.smtp_settings = {\n  address: config.host,\n  user_name: config.username,\n  password: config.password,\n  port: config.port,\n  authentication: config.authentication_type&.to_sym,\n  enable_starttls: config.enable_starttls?,\n  enable_starttls_auto: config.enable_starttls_auto?,\n  openssl_verify_mode: config.openssl_verify_mode\n}\n"
  },
  {
    "path": "config/initializers/smtp_extensions.rb",
    "content": "# frozen_string_literal: true\n\nmodule Net\n  class SMTP\n\n    attr_accessor :source_address\n\n    def secure_socket?\n      return false unless @socket\n\n      @socket.io.is_a?(OpenSSL::SSL::SSLSocket)\n    end\n\n    #\n    # We had an issue where a message was sent to a server and was greylisted. It returned\n    # a Net::SMTPUnknownError error. We then tried to send another message on the same\n    # connection after running `rset` the next message didn't raise any exceptions because\n    # net/smtp returns a '200 dummy reply code' and doesn't raise any exceptions.\n    #\n    def rset\n      @error_occurred = false\n      getok(\"RSET\")\n    end\n\n    def rset_errors\n      @error_occurred = false\n    end\n\n    private\n\n    def tcp_socket(address, port)\n      TCPSocket.open(address, port, source_address)\n    end\n\n    class Response\n\n      def message\n        @string\n      end\n\n    end\n\n  end\nend\n"
  },
  {
    "path": "config/initializers/trusted_proxies.rb",
    "content": "# frozen_string_literal: true\n\nRack::Request.ip_filter = lambda { |ip|\n  if Postal::Config.postal.trusted_proxies&.any? { |net| net.include?(ip) } ||\n     ip.match(/\\A127\\.0\\.0\\.1\\Z|\\A::1\\Z|\\Afd[0-9a-f]{2}:.+|\\Alocalhost\\Z|\\Aunix\\Z|\\Aunix:/i)\n    true\n  else\n    false\n  end\n}\n"
  },
  {
    "path": "config/initializers/wrap_parameters.rb",
    "content": "# frozen_string_literal: true\n\n# Be sure to restart your server when you modify this file.\n\n# This file contains settings for ActionController::ParamsWrapper which\n# is enabled by default.\n\n# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.\nActiveSupport.on_load(:action_controller) do\n  wrap_parameters format: [:json]\nend\n\n# To enable root element in JSON for ActiveRecord objects.\n# ActiveSupport.on_load(:active_record) do\n#   self.include_root_in_json = true\n# end\n"
  },
  {
    "path": "config/initializers/zeitwerk.rb",
    "content": "# frozen_string_literal: true\n\nRails.autoloaders.each do |autoloader|\n  # Ignore the message DB migrations directory as it doesn't follow\n  # Zeitwerk's conventions and is always loaded and executed in order.\n  autoloader.ignore(Rails.root.join(\"lib/postal/message_db/migrations\"))\nend\n"
  },
  {
    "path": "config/locales/en.yml",
    "content": "# Files in the config/locales directory are used for internationalization\n# and are automatically loaded by Rails. If you want to use locales other\n# than English, add the necessary files in this directory.\n#\n# To use the locales, use `I18n.t`:\n#\n#     I18n.t 'hello'\n#\n# In views, this is aliased to just `t`:\n#\n#     <%= t('hello') %>\n#\n# To use a different locale, set it with `I18n.locale`:\n#\n#     I18n.locale = :es\n#\n# This would use the information in config/locales/es.yml.\n#\n# To learn more, please read the Rails Internationalization guide\n# available at http://guides.rubyonrails.org/i18n.html.\n\nen:\n  hello: \"Hello world\"\n  activerecord:\n    attributes:\n      organization:\n        permalink: Short name\n      server:\n        permalink: Short name\n      domain:\n        verification_method: Verify Method\n      http_endpoint:\n        url: URL\n      user:\n        email_address: E-Mail address\n      webhook:\n        url: URL\n        sign: Sign requests\n\n  server_statuses:\n    live: Live\n    development: Dev\n    suspended: Suspended\n  route_spam_modes:\n    quarantine: Spam will be quarantined\n    mark: Spam will be marked\n\n  http_endpoint_formats:\n    hash: Delivered as a hash\n    raw_message: Delivered as the raw message\n  http_endpoint_encodings:\n    body_as_json: Sent in the body as JSON\n    form_data: Sent as form data\n\n  webhook_events:\n    message_sent: An e-mail has been successfully delivered to its endpoint (either SMTP or HTTP).\n    message_delayed: An e-mail has been delayed due to an issue with the receiving endpoint. It will be retried automatically.\n    message_delivery_failed: An e-mail cannot be delivered to its endpoint. This is a permanent failure so it will no be retried.\n    message_held: An e-mail has been held in Postal. This will be because a limit has been reached or your server is in development mode.\n    message_bounced: We received a bounce message in response to an email which had previously been successfully sent.\n    message_link_clicked: A link in one of your outbound messages has been clicked.\n    message_loaded: A message you have sent has been loaded.\n    domain_dns_error: This will be triggered when we detect an issue with the DNS configuration for any domain for this server.\n    send_limit_approaching: This will be triggered when your mail server is approaching its send limit. It will only be sent once per hour.\n    send_limit_exceeded: This will be triggered when your mail server exceeded its send limit.\n\n  currencies:\n    gbp: GBP - Great British Pound (£)\n    usd: USD - United States Dollar ($)\n    eur: EUR - Euro (€)\n\n  route_modes:\n    accept: Accept message with no endpoint\n    hold: Accept message and put message in hold queue\n    bounce: Accept message and immediately send bounce to sender\n    reject: Do not accept any incoming messages\n\n  renewal_issues:\n    no_payment_card: You don't have a payment card on file\n    payment_declined: The payment for this service was declined\n"
  },
  {
    "path": "config/puma.rb",
    "content": "# frozen_string_literal: true\n\nrequire_relative \"../lib/postal/config\"\n\nthreads_count = Postal::Config.web_server.max_threads\nthreads         threads_count, threads_count\nbind_address  = ENV.fetch(\"BIND_ADDRESS\", Postal::Config.web_server.default_bind_address)\nbind_port     = ENV.fetch(\"PORT\", Postal::Config.web_server.default_port)\nbind            \"tcp://#{bind_address}:#{bind_port}\"\nenvironment     Postal::Config.rails.environment || \"development\"\nprune_bundler\nquiet false\n"
  },
  {
    "path": "config/routes.rb",
    "content": "# frozen_string_literal: true\n\nRails.application.routes.draw do\n  # Legacy API Routes\n  match \"/api/v1/send/message\" => \"legacy_api/send#message\", via: [:get, :post, :patch, :put]\n  match \"/api/v1/send/raw\" => \"legacy_api/send#raw\", via: [:get, :post, :patch, :put]\n  match \"/api/v1/messages/message\" => \"legacy_api/messages#message\", via: [:get, :post, :patch, :put]\n  match \"/api/v1/messages/deliveries\" => \"legacy_api/messages#deliveries\", via: [:get, :post, :patch, :put]\n\n  scope \"org/:org_permalink\", as: \"organization\" do\n    resources :domains, only: [:index, :new, :create, :destroy] do\n      match :verify, on: :member, via: [:get, :post]\n      get :setup, on: :member\n      post :check, on: :member\n    end\n    resources :servers, except: [:index] do\n      resources :domains, only: [:index, :new, :create, :destroy] do\n        match :verify, on: :member, via: [:get, :post]\n        get :setup, on: :member\n        post :check, on: :member\n      end\n      resources :track_domains do\n        post :toggle_ssl, on: :member\n        post :check, on: :member\n      end\n      resources :credentials\n      resources :routes\n      resources :http_endpoints\n      resources :smtp_endpoints\n      resources :address_endpoints\n      resources :ip_pool_rules\n      resources :messages do\n        get :incoming, on: :collection\n        get :outgoing, on: :collection\n        get :held, on: :collection\n        get :activity, on: :member\n        get :plain, on: :member\n        get :html, on: :member\n        get :html_raw, on: :member\n        get :attachments, on: :member\n        get :headers, on: :member\n        get :attachment, on: :member\n        get :download, on: :member\n        get :spam_checks, on: :member\n        post :retry, on: :member\n        post :cancel_hold, on: :member\n        get :suppressions, on: :collection\n        delete :remove_from_queue, on: :member\n        get :deliveries, on: :member\n      end\n      resources :webhooks do\n        get :history, on: :collection\n        get \"history/:uuid\", on: :collection, action: \"history_request\", as: \"history_request\"\n      end\n      get :limits, on: :member\n      get :retention, on: :member\n      get :queue, on: :member\n      get :spam, on: :member\n      get :delete, on: :member\n      get \"help/outgoing\" => \"help#outgoing\"\n      get \"help/incoming\" => \"help#incoming\"\n      get :advanced, on: :member\n      post :suspend, on: :member\n      post :unsuspend, on: :member\n    end\n\n    resources :ip_pool_rules\n    resources :ip_pools, controller: \"organization_ip_pools\" do\n      put :assignments, on: :collection\n    end\n    root \"servers#index\"\n    get \"settings\" => \"organizations#edit\"\n    patch \"settings\" => \"organizations#update\"\n    get \"delete\" => \"organizations#delete\"\n    delete \"delete\" => \"organizations#destroy\"\n  end\n\n  resources :organizations, except: [:index]\n  resources :users\n  resources :ip_pools do\n    resources :ip_addresses\n  end\n\n  get \"settings\" => \"user#edit\"\n  patch \"settings\" => \"user#update\"\n  post \"persist\" => \"sessions#persist\"\n\n  get \"login\" => \"sessions#new\"\n  post \"login\" => \"sessions#create\"\n  delete \"logout\" => \"sessions#destroy\"\n  match \"login/reset\" => \"sessions#begin_password_reset\", :via => [:get, :post]\n  match \"login/reset/:token\" => \"sessions#finish_password_reset\", :via => [:get, :post]\n\n  if Postal::Config.oidc.enabled?\n    get \"auth/oidc/callback\", to: \"sessions#create_from_oidc\"\n  end\n\n  get \".well-known/jwks.json\" => \"well_known#jwks\"\n\n  get \"ip\" => \"sessions#ip\"\n\n  root \"organizations#index\"\nend\n"
  },
  {
    "path": "config.ru",
    "content": "# frozen_string_literal: true\n\n# This file is used by Rack-based servers to start the application.\n\nrequire_relative \"config/environment\"\nrun Rails.application\n"
  },
  {
    "path": "db/migrate/20161003195209_create_authie_sessions.authie.rb",
    "content": "# frozen_string_literal: true\n\n# This migration comes from authie (originally 20141012174250)\nclass CreateAuthieSessions < ActiveRecord::Migration\n\n  def change\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20161003195210_add_indexes_to_authie_sessions.authie.rb",
    "content": "# frozen_string_literal: true\n\n# This migration comes from authie (originally 20141013115205)\nclass AddIndexesToAuthieSessions < ActiveRecord::Migration\n\n  def change\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20161003195211_add_parent_id_to_authie_sessions.authie.rb",
    "content": "# frozen_string_literal: true\n\n# This migration comes from authie (originally 20150109144120)\nclass AddParentIdToAuthieSessions < ActiveRecord::Migration\n\n  def change\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20161003195212_add_two_factor_auth_fields_to_authie.authie.rb",
    "content": "# frozen_string_literal: true\n\n# This migration comes from authie (originally 20150305135400)\nclass AddTwoFactorAuthFieldsToAuthie < ActiveRecord::Migration\n\n  def change\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20170418200606_initial_schema.rb",
    "content": "# frozen_string_literal: true\n\nclass InitialSchema < ActiveRecord::Migration\n\n  def up\n    create_table \"additional_route_endpoints\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"route_id\"\n      t.string   \"endpoint_type\"\n      t.integer  \"endpoint_id\"\n      t.datetime \"created_at\",    null: false\n      t.datetime \"updated_at\",    null: false\n    end\n\n    create_table \"address_endpoints\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"server_id\"\n      t.string   \"uuid\"\n      t.string   \"address\"\n      t.datetime \"last_used_at\"\n      t.datetime \"created_at\",   null: false\n      t.datetime \"updated_at\",   null: false\n    end\n\n    create_table \"authie_sessions\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.string   \"token\"\n      t.string   \"browser_id\"\n      t.integer  \"user_id\"\n      t.boolean  \"active\", default: true\n      t.text     \"data\", limit: 65_535\n      t.datetime \"expires_at\"\n      t.datetime \"login_at\"\n      t.string   \"login_ip\"\n      t.datetime \"last_activity_at\"\n      t.string   \"last_activity_ip\"\n      t.string   \"last_activity_path\"\n      t.string   \"user_agent\"\n      t.datetime \"created_at\"\n      t.datetime \"updated_at\"\n      t.string   \"user_type\"\n      t.integer  \"parent_id\"\n      t.datetime \"two_factored_at\"\n      t.string   \"two_factored_ip\"\n      t.integer  \"requests\", default: 0\n      t.datetime \"password_seen_at\"\n      t.string   \"token_hash\"\n      t.index [\"browser_id\"], name: \"index_authie_sessions_on_browser_id\", length: { browser_id: 8 }, using: :btree\n      t.index [\"token\"], name: \"index_authie_sessions_on_token\", length: { token: 8 }, using: :btree\n      t.index [\"user_id\"], name: \"index_authie_sessions_on_user_id\", using: :btree\n    end\n\n    create_table \"credentials\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"server_id\"\n      t.string   \"key\"\n      t.string   \"type\"\n      t.string   \"name\"\n      t.text     \"options\", limit: 65_535\n      t.datetime \"last_used_at\",               precision: 6\n      t.datetime \"created_at\",                 precision: 6\n      t.datetime \"updated_at\",                 precision: 6\n      t.boolean  \"hold\", default: false\n    end\n\n    create_table \"domains\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"server_id\"\n      t.string   \"uuid\"\n      t.string   \"name\"\n      t.string   \"verification_token\"\n      t.string   \"verification_method\"\n      t.datetime \"verified_at\"\n      t.text     \"dkim_private_key\", limit: 65_535\n      t.datetime \"created_at\",                           precision: 6\n      t.datetime \"updated_at\",                           precision: 6\n      t.datetime \"dns_checked_at\",                       precision: 6\n      t.string   \"spf_status\"\n      t.string   \"spf_error\"\n      t.string   \"dkim_status\"\n      t.string   \"dkim_error\"\n      t.string   \"mx_status\"\n      t.string   \"mx_error\"\n      t.string   \"return_path_status\"\n      t.string   \"return_path_error\"\n      t.boolean  \"outgoing\",                                           default: true\n      t.boolean  \"incoming\",                                           default: true\n      t.string   \"owner_type\"\n      t.integer  \"owner_id\"\n      t.string   \"dkim_identifier_string\"\n      t.boolean  \"use_for_any\"\n      t.index [\"server_id\"], name: \"index_domains_on_server_id\", using: :btree\n      t.index [\"uuid\"], name: \"index_domains_on_uuid\", length: { uuid: 8 }, using: :btree\n    end\n\n    create_table \"http_endpoints\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"server_id\"\n      t.string   \"uuid\"\n      t.string   \"name\"\n      t.string   \"url\"\n      t.string   \"encoding\"\n      t.string   \"format\"\n      t.boolean  \"strip_replies\", default: false\n      t.text     \"error\", limit: 65_535\n      t.datetime \"disabled_until\",                    precision: 6\n      t.datetime \"last_used_at\",                      precision: 6\n      t.datetime \"created_at\",                        precision: 6\n      t.datetime \"updated_at\",                        precision: 6\n      t.boolean  \"include_attachments\", default: true\n      t.integer  \"timeout\"\n    end\n\n    create_table \"ip_addresses\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"ip_pool_id\"\n      t.string   \"ipv4\"\n      t.string   \"ipv6\"\n      t.datetime \"created_at\", precision: 6\n      t.datetime \"updated_at\", precision: 6\n      t.string   \"hostname\"\n    end\n\n    create_table \"ip_pool_rules\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.string   \"uuid\"\n      t.string   \"owner_type\"\n      t.integer  \"owner_id\"\n      t.integer  \"ip_pool_id\"\n      t.text     \"from_text\",  limit: 65_535\n      t.text     \"to_text\",    limit: 65_535\n      t.datetime \"created_at\",               null: false\n      t.datetime \"updated_at\",               null: false\n    end\n\n    create_table \"ip_pools\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.string   \"name\"\n      t.string   \"uuid\"\n      t.datetime \"created_at\", precision: 6\n      t.datetime \"updated_at\", precision: 6\n      t.boolean  \"default\", default: false\n      t.string   \"type\"\n      t.index [\"uuid\"], name: \"index_ip_pools_on_uuid\", length: { uuid: 8 }, using: :btree\n    end\n\n    create_table \"organization_ip_pools\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"organization_id\"\n      t.integer  \"ip_pool_id\"\n      t.datetime \"created_at\",      null: false\n      t.datetime \"updated_at\",      null: false\n    end\n\n    create_table \"organization_users\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"organization_id\"\n      t.integer  \"user_id\"\n      t.datetime \"created_at\", precision: 6\n      t.boolean  \"admin\",                         default: false\n      t.boolean  \"all_servers\",                   default: true\n      t.string   \"user_type\"\n    end\n\n    create_table \"organizations\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.string   \"uuid\"\n      t.string   \"name\"\n      t.string   \"permalink\"\n      t.string   \"time_zone\"\n      t.datetime \"created_at\",        precision: 6\n      t.datetime \"updated_at\",        precision: 6\n      t.integer  \"ip_pool_id\"\n      t.integer  \"owner_id\"\n      t.datetime \"deleted_at\",        precision: 6\n      t.datetime \"suspended_at\",      precision: 6\n      t.string   \"suspension_reason\"\n      t.index [\"permalink\"], name: \"index_organizations_on_permalink\", length: { permalink: 8 }, using: :btree\n      t.index [\"uuid\"], name: \"index_organizations_on_uuid\", length: { uuid: 8 }, using: :btree\n    end\n\n    create_table \"queued_messages\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"server_id\"\n      t.integer  \"message_id\"\n      t.string   \"domain\"\n      t.string   \"locked_by\"\n      t.datetime \"locked_at\", precision: 6\n      t.datetime \"retry_after\"\n      t.datetime \"created_at\",    precision: 6\n      t.datetime \"updated_at\",    precision: 6\n      t.integer  \"ip_address_id\"\n      t.integer  \"attempts\", default: 0\n      t.integer  \"route_id\"\n      t.boolean  \"manual\", default: false\n      t.string   \"batch_key\"\n      t.index [\"domain\"], name: \"index_queued_messages_on_domain\", length: { domain: 8 }, using: :btree\n      t.index [\"message_id\"], name: \"index_queued_messages_on_message_id\", using: :btree\n      t.index [\"server_id\"], name: \"index_queued_messages_on_server_id\", using: :btree\n    end\n\n    create_table \"routes\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.string   \"uuid\"\n      t.integer  \"server_id\"\n      t.integer  \"domain_id\"\n      t.integer  \"endpoint_id\"\n      t.string   \"endpoint_type\"\n      t.string   \"name\"\n      t.string   \"spam_mode\"\n      t.datetime \"created_at\",    precision: 6\n      t.datetime \"updated_at\",    precision: 6\n      t.string   \"token\"\n      t.string   \"mode\"\n      t.index [\"token\"], name: \"index_routes_on_token\", length: { token: 6 }, using: :btree\n    end\n\n    create_table \"servers\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"organization_id\"\n      t.string   \"uuid\"\n      t.string   \"name\"\n      t.string   \"mode\"\n      t.integer  \"ip_pool_id\"\n      t.datetime \"created_at\",                                       precision: 6\n      t.datetime \"updated_at\",                                       precision: 6\n      t.string   \"permalink\"\n      t.integer  \"send_limit\"\n      t.datetime \"deleted_at\", precision: 6\n      t.integer  \"message_retention_days\"\n      t.integer  \"raw_message_retention_days\"\n      t.integer  \"raw_message_retention_size\"\n      t.boolean  \"allow_sender\", default: false\n      t.string   \"token\"\n      t.datetime \"send_limit_approaching_at\",                        precision: 6\n      t.datetime \"send_limit_approaching_notified_at\",               precision: 6\n      t.datetime \"send_limit_exceeded_at\",                           precision: 6\n      t.datetime \"send_limit_exceeded_notified_at\",                  precision: 6\n      t.decimal  \"spam_threshold\",                                   precision: 8, scale: 2\n      t.decimal  \"spam_failure_threshold\",                           precision: 8, scale: 2\n      t.string   \"postmaster_address\"\n      t.datetime \"suspended_at\",                                     precision: 6\n      t.decimal  \"outbound_spam_threshold\",                          precision: 8, scale: 2\n      t.text     \"domains_not_to_click_track\", limit: 65_535\n      t.string   \"suspension_reason\"\n      t.boolean  \"log_smtp_data\", default: false\n      t.index [\"organization_id\"], name: \"index_servers_on_organization_id\", using: :btree\n      t.index [\"permalink\"], name: \"index_servers_on_permalink\", length: { permalink: 6 }, using: :btree\n      t.index [\"token\"], name: \"index_servers_on_token\", length: { token: 6 }, using: :btree\n      t.index [\"uuid\"], name: \"index_servers_on_uuid\", length: { uuid: 8 }, using: :btree\n    end\n\n    create_table \"smtp_endpoints\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"server_id\"\n      t.string   \"uuid\"\n      t.string   \"name\"\n      t.string   \"hostname\"\n      t.string   \"ssl_mode\"\n      t.integer  \"port\"\n      t.text     \"error\", limit: 65_535\n      t.datetime \"disabled_until\",               precision: 6\n      t.datetime \"last_used_at\",                 precision: 6\n      t.datetime \"created_at\",                   precision: 6\n      t.datetime \"updated_at\",                   precision: 6\n    end\n\n    create_table \"statistics\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.bigint \"total_messages\", default: 0\n      t.bigint \"total_outgoing\", default: 0\n      t.bigint \"total_incoming\", default: 0\n    end\n\n    create_table \"track_certificates\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.string   \"domain\"\n      t.text     \"certificate\",         limit: 65_535\n      t.text     \"intermediaries\",      limit: 65_535\n      t.text     \"key\",                 limit: 65_535\n      t.datetime \"expires_at\"\n      t.datetime \"renew_after\"\n      t.string   \"verification_path\"\n      t.string   \"verification_string\"\n      t.datetime \"created_at\",                        null: false\n      t.datetime \"updated_at\",                        null: false\n      t.index [\"domain\"], name: \"index_track_certificates_on_domain\", length: { domain: 8 }, using: :btree\n    end\n\n    create_table \"track_domains\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.string   \"uuid\"\n      t.integer  \"server_id\"\n      t.integer  \"domain_id\"\n      t.string   \"name\"\n      t.datetime \"dns_checked_at\"\n      t.string   \"dns_status\"\n      t.string   \"dns_error\"\n      t.datetime \"created_at\",                                          null: false\n      t.datetime \"updated_at\",                                          null: false\n      t.boolean  \"ssl_enabled\",                          default: true\n      t.boolean  \"track_clicks\",                         default: true\n      t.boolean  \"track_loads\",                          default: true\n      t.text     \"excluded_click_domains\", limit: 65_535\n    end\n\n    create_table \"user_invites\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.string   \"uuid\"\n      t.string   \"email_address\"\n      t.datetime \"expires_at\",    precision: 6\n      t.datetime \"created_at\",    precision: 6\n      t.datetime \"updated_at\",    precision: 6\n      t.index [\"uuid\"], name: \"index_user_invites_on_uuid\", length: { uuid: 12 }, using: :btree\n    end\n\n    create_table \"users\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.string   \"uuid\"\n      t.string   \"first_name\"\n      t.string   \"last_name\"\n      t.string   \"email_address\"\n      t.string   \"password_digest\"\n      t.string   \"time_zone\"\n      t.string   \"email_verification_token\"\n      t.datetime \"email_verified_at\"\n      t.datetime \"created_at\",                       precision: 6\n      t.datetime \"updated_at\",                       precision: 6\n      t.string   \"password_reset_token\"\n      t.datetime \"password_reset_token_valid_until\"\n      t.boolean  \"admin\", default: false\n      t.index [\"email_address\"], name: \"index_users_on_email_address\", length: { email_address: 8 }, using: :btree\n      t.index [\"uuid\"], name: \"index_users_on_uuid\", length: { uuid: 8 }, using: :btree\n    end\n\n    create_table \"webhook_events\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"webhook_id\"\n      t.string   \"event\"\n      t.datetime \"created_at\", precision: 6\n      t.index [\"webhook_id\"], name: \"index_webhook_events_on_webhook_id\", using: :btree\n    end\n\n    create_table \"webhook_requests\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"server_id\"\n      t.integer  \"webhook_id\"\n      t.string   \"url\"\n      t.string   \"event\"\n      t.string   \"uuid\"\n      t.text     \"payload\", limit: 65_535\n      t.integer  \"attempts\", default: 0\n      t.datetime \"retry_after\", precision: 6\n      t.text     \"error\", limit: 65_535\n      t.datetime \"created_at\", precision: 6\n    end\n\n    create_table \"webhooks\", force: :cascade, options: \"ENGINE=InnoDB DEFAULT CHARSET=utf8mb4\" do |t|\n      t.integer  \"server_id\"\n      t.string   \"uuid\"\n      t.string   \"name\"\n      t.string   \"url\"\n      t.datetime \"last_used_at\"\n      t.boolean  \"all_events\",                 default: false\n      t.boolean  \"enabled\",                    default: true\n      t.boolean  \"sign\",                       default: true\n      t.datetime \"created_at\",   precision: 6\n      t.datetime \"updated_at\",   precision: 6\n      t.index [\"server_id\"], name: \"index_webhooks_on_server_id\", using: :btree\n    end\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20170421195414_add_token_hashes_to_authie_sessions.authie.rb",
    "content": "# frozen_string_literal: true\n\n# This migration comes from authie (originally 20170417170000)\nclass AddTokenHashesToAuthieSessions < ActiveRecord::Migration\n\n  def change\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20170421195415_add_index_to_token_hashes_on_authie_sessions.authie.rb",
    "content": "# frozen_string_literal: true\n\n# This migration comes from authie (originally 20170421174100)\nclass AddIndexToTokenHashesOnAuthieSessions < ActiveRecord::Migration\n\n  def change\n    add_index :authie_sessions, :token_hash, length: 8\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20170428153353_remove_type_from_ip_pools.rb",
    "content": "# frozen_string_literal: true\n\nclass RemoveTypeFromIPPools < ActiveRecord::Migration[5.0]\n\n  def change\n    remove_column :ip_pools, :type, :string\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20180216114344_add_host_to_authie_sessions.authie.rb",
    "content": "# frozen_string_literal: true\n\n# This migration comes from authie (originally 20180215152200)\nclass AddHostToAuthieSessions < ActiveRecord::Migration[4.2]\n\n  def change\n    add_column :authie_sessions, :host, :string\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20200717083943_add_uuid_to_credentials.rb",
    "content": "# frozen_string_literal: true\n\nclass AddUUIDToCredentials < ActiveRecord::Migration[5.2]\n\n  def change\n    add_column :credentials, :uuid, :string\n    Credential.find_each do |c|\n      c.update_column(:uuid, SecureRandom.uuid)\n    end\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20210727210551_add_priority_to_ip_addresses.rb",
    "content": "# frozen_string_literal: true\n\nclass AddPriorityToIPAddresses < ActiveRecord::Migration[5.2]\n\n  def change\n    add_column :ip_addresses, :priority, :integer\n    IPAddress.where(priority: nil).update_all(priority: 100)\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20240206173036_add_privacy_mode_to_servers.rb",
    "content": "# frozen_string_literal: true\n\nclass AddPrivacyModeToServers < ActiveRecord::Migration[6.1]\n\n  def change\n    add_column :servers, :privacy_mode, :boolean, default: false\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20240213165450_create_worker_roles.rb",
    "content": "# frozen_string_literal: true\n\nclass CreateWorkerRoles < ActiveRecord::Migration[6.1]\n\n  def change\n    create_table :worker_roles do |t|\n      t.string :role\n      t.string :worker\n      t.datetime :acquired_at\n      t.index :role, unique: true\n    end\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20240213171830_create_scheduled_tasks.rb",
    "content": "# frozen_string_literal: true\n\nclass CreateScheduledTasks < ActiveRecord::Migration[6.1]\n\n  def change\n    create_table :scheduled_tasks do |t|\n      t.string :name\n      t.datetime :next_run_after\n      t.index :name, unique: true\n    end\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20240214132253_add_lock_fields_to_webhook_requests.rb",
    "content": "# frozen_string_literal: true\n\nclass AddLockFieldsToWebhookRequests < ActiveRecord::Migration[6.1]\n\n  def change\n    add_column :webhook_requests, :locked_by, :string\n    add_column :webhook_requests, :locked_at, :datetime\n\n    add_index :webhook_requests, :locked_by\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20240223141500_add_two_factor_required_to_sessions.authie.rb",
    "content": "# frozen_string_literal: true\n\n# This migration comes from authie (originally 20220502180100)\nclass AddTwoFactorRequiredToSessions < ActiveRecord::Migration[6.1]\n\n  def change\n    add_column :authie_sessions, :skip_two_factor, :boolean, default: false\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20240223141501_add_countries_to_authie_sessions.authie.rb",
    "content": "# frozen_string_literal: true\n\n# This migration comes from authie (originally 20230627165500)\nclass AddCountriesToAuthieSessions < ActiveRecord::Migration[6.1]\n\n  def change\n    add_column :authie_sessions, :login_ip_country, :string\n    add_column :authie_sessions, :two_factored_ip_country, :string\n    add_column :authie_sessions, :last_activity_ip_country, :string\n  end\n\nend\n"
  },
  {
    "path": "db/migrate/20240311205229_add_oidc_fields_to_user.rb",
    "content": "# frozen_string_literal: true\n\nclass AddOIDCFieldsToUser < ActiveRecord::Migration[7.0]\n\n  def change\n    add_column :users, :oidc_uid, :string\n    add_column :users, :oidc_issuer, :string\n  end\n\nend\n"
  },
  {
    "path": "db/schema.rb",
    "content": "# This file is auto-generated from the current state of the database. Instead\n# of editing this file, please use the migrations feature of Active Record to\n# incrementally modify your database, and then regenerate this schema definition.\n#\n# This file is the source Rails uses to define your schema when running `bin/rails\n# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to\n# be faster and is potentially less error prone than running all of your\n# migrations from scratch. Old migrations may fail to apply correctly if those\n# migrations use external dependencies or application code.\n#\n# It's strongly recommended that you check this file into your version control system.\n\nActiveRecord::Schema[7.0].define(version: 2024_03_11_205229) do\n  create_table \"additional_route_endpoints\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"route_id\"\n    t.string \"endpoint_type\"\n    t.integer \"endpoint_id\"\n    t.datetime \"created_at\", precision: nil, null: false\n    t.datetime \"updated_at\", precision: nil, null: false\n  end\n\n  create_table \"address_endpoints\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"server_id\"\n    t.string \"uuid\"\n    t.string \"address\"\n    t.datetime \"last_used_at\", precision: nil\n    t.datetime \"created_at\", precision: nil, null: false\n    t.datetime \"updated_at\", precision: nil, null: false\n  end\n\n  create_table \"authie_sessions\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.string \"token\"\n    t.string \"browser_id\"\n    t.integer \"user_id\"\n    t.boolean \"active\", default: true\n    t.text \"data\"\n    t.datetime \"expires_at\", precision: nil\n    t.datetime \"login_at\", precision: nil\n    t.string \"login_ip\"\n    t.datetime \"last_activity_at\", precision: nil\n    t.string \"last_activity_ip\"\n    t.string \"last_activity_path\"\n    t.string \"user_agent\"\n    t.datetime \"created_at\", precision: nil\n    t.datetime \"updated_at\", precision: nil\n    t.string \"user_type\"\n    t.integer \"parent_id\"\n    t.datetime \"two_factored_at\", precision: nil\n    t.string \"two_factored_ip\"\n    t.integer \"requests\", default: 0\n    t.datetime \"password_seen_at\", precision: nil\n    t.string \"token_hash\"\n    t.string \"host\"\n    t.boolean \"skip_two_factor\", default: false\n    t.string \"login_ip_country\"\n    t.string \"two_factored_ip_country\"\n    t.string \"last_activity_ip_country\"\n    t.index [\"browser_id\"], name: \"index_authie_sessions_on_browser_id\", length: 8\n    t.index [\"token\"], name: \"index_authie_sessions_on_token\", length: 8\n    t.index [\"token_hash\"], name: \"index_authie_sessions_on_token_hash\", length: 8\n    t.index [\"user_id\"], name: \"index_authie_sessions_on_user_id\"\n  end\n\n  create_table \"credentials\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"server_id\"\n    t.string \"key\"\n    t.string \"type\"\n    t.string \"name\"\n    t.text \"options\"\n    t.datetime \"last_used_at\"\n    t.datetime \"created_at\"\n    t.datetime \"updated_at\"\n    t.boolean \"hold\", default: false\n    t.string \"uuid\"\n  end\n\n  create_table \"domains\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"server_id\"\n    t.string \"uuid\"\n    t.string \"name\"\n    t.string \"verification_token\"\n    t.string \"verification_method\"\n    t.datetime \"verified_at\", precision: nil\n    t.text \"dkim_private_key\"\n    t.datetime \"created_at\"\n    t.datetime \"updated_at\"\n    t.datetime \"dns_checked_at\"\n    t.string \"spf_status\"\n    t.string \"spf_error\"\n    t.string \"dkim_status\"\n    t.string \"dkim_error\"\n    t.string \"mx_status\"\n    t.string \"mx_error\"\n    t.string \"return_path_status\"\n    t.string \"return_path_error\"\n    t.boolean \"outgoing\", default: true\n    t.boolean \"incoming\", default: true\n    t.string \"owner_type\"\n    t.integer \"owner_id\"\n    t.string \"dkim_identifier_string\"\n    t.boolean \"use_for_any\"\n    t.index [\"server_id\"], name: \"index_domains_on_server_id\"\n    t.index [\"uuid\"], name: \"index_domains_on_uuid\", length: 8\n  end\n\n  create_table \"http_endpoints\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"server_id\"\n    t.string \"uuid\"\n    t.string \"name\"\n    t.string \"url\"\n    t.string \"encoding\"\n    t.string \"format\"\n    t.boolean \"strip_replies\", default: false\n    t.text \"error\"\n    t.datetime \"disabled_until\"\n    t.datetime \"last_used_at\"\n    t.datetime \"created_at\"\n    t.datetime \"updated_at\"\n    t.boolean \"include_attachments\", default: true\n    t.integer \"timeout\"\n  end\n\n  create_table \"ip_addresses\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"ip_pool_id\"\n    t.string \"ipv4\"\n    t.string \"ipv6\"\n    t.datetime \"created_at\"\n    t.datetime \"updated_at\"\n    t.string \"hostname\"\n    t.integer \"priority\"\n  end\n\n  create_table \"ip_pool_rules\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.string \"uuid\"\n    t.string \"owner_type\"\n    t.integer \"owner_id\"\n    t.integer \"ip_pool_id\"\n    t.text \"from_text\"\n    t.text \"to_text\"\n    t.datetime \"created_at\", precision: nil, null: false\n    t.datetime \"updated_at\", precision: nil, null: false\n  end\n\n  create_table \"ip_pools\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.string \"name\"\n    t.string \"uuid\"\n    t.datetime \"created_at\"\n    t.datetime \"updated_at\"\n    t.boolean \"default\", default: false\n    t.index [\"uuid\"], name: \"index_ip_pools_on_uuid\", length: 8\n  end\n\n  create_table \"organization_ip_pools\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"organization_id\"\n    t.integer \"ip_pool_id\"\n    t.datetime \"created_at\", precision: nil, null: false\n    t.datetime \"updated_at\", precision: nil, null: false\n  end\n\n  create_table \"organization_users\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"organization_id\"\n    t.integer \"user_id\"\n    t.datetime \"created_at\"\n    t.boolean \"admin\", default: false\n    t.boolean \"all_servers\", default: true\n    t.string \"user_type\"\n  end\n\n  create_table \"organizations\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.string \"uuid\"\n    t.string \"name\"\n    t.string \"permalink\"\n    t.string \"time_zone\"\n    t.datetime \"created_at\"\n    t.datetime \"updated_at\"\n    t.integer \"ip_pool_id\"\n    t.integer \"owner_id\"\n    t.datetime \"deleted_at\"\n    t.datetime \"suspended_at\"\n    t.string \"suspension_reason\"\n    t.index [\"permalink\"], name: \"index_organizations_on_permalink\", length: 8\n    t.index [\"uuid\"], name: \"index_organizations_on_uuid\", length: 8\n  end\n\n  create_table \"queued_messages\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"server_id\"\n    t.integer \"message_id\"\n    t.string \"domain\"\n    t.string \"locked_by\"\n    t.datetime \"locked_at\"\n    t.datetime \"retry_after\", precision: nil\n    t.datetime \"created_at\"\n    t.datetime \"updated_at\"\n    t.integer \"ip_address_id\"\n    t.integer \"attempts\", default: 0\n    t.integer \"route_id\"\n    t.boolean \"manual\", default: false\n    t.string \"batch_key\"\n    t.index [\"domain\"], name: \"index_queued_messages_on_domain\", length: 8\n    t.index [\"message_id\"], name: \"index_queued_messages_on_message_id\"\n    t.index [\"server_id\"], name: \"index_queued_messages_on_server_id\"\n  end\n\n  create_table \"routes\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.string \"uuid\"\n    t.integer \"server_id\"\n    t.integer \"domain_id\"\n    t.integer \"endpoint_id\"\n    t.string \"endpoint_type\"\n    t.string \"name\"\n    t.string \"spam_mode\"\n    t.datetime \"created_at\"\n    t.datetime \"updated_at\"\n    t.string \"token\"\n    t.string \"mode\"\n    t.index [\"token\"], name: \"index_routes_on_token\", length: 6\n  end\n\n  create_table \"scheduled_tasks\", charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.string \"name\"\n    t.datetime \"next_run_after\", precision: nil\n    t.index [\"name\"], name: \"index_scheduled_tasks_on_name\", unique: true\n  end\n\n  create_table \"servers\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"organization_id\"\n    t.string \"uuid\"\n    t.string \"name\"\n    t.string \"mode\"\n    t.integer \"ip_pool_id\"\n    t.datetime \"created_at\"\n    t.datetime \"updated_at\"\n    t.string \"permalink\"\n    t.integer \"send_limit\"\n    t.datetime \"deleted_at\"\n    t.integer \"message_retention_days\"\n    t.integer \"raw_message_retention_days\"\n    t.integer \"raw_message_retention_size\"\n    t.boolean \"allow_sender\", default: false\n    t.string \"token\"\n    t.datetime \"send_limit_approaching_at\"\n    t.datetime \"send_limit_approaching_notified_at\"\n    t.datetime \"send_limit_exceeded_at\"\n    t.datetime \"send_limit_exceeded_notified_at\"\n    t.decimal \"spam_threshold\", precision: 8, scale: 2\n    t.decimal \"spam_failure_threshold\", precision: 8, scale: 2\n    t.string \"postmaster_address\"\n    t.datetime \"suspended_at\"\n    t.decimal \"outbound_spam_threshold\", precision: 8, scale: 2\n    t.text \"domains_not_to_click_track\"\n    t.string \"suspension_reason\"\n    t.boolean \"log_smtp_data\", default: false\n    t.boolean \"privacy_mode\", default: false\n    t.index [\"organization_id\"], name: \"index_servers_on_organization_id\"\n    t.index [\"permalink\"], name: \"index_servers_on_permalink\", length: 6\n    t.index [\"token\"], name: \"index_servers_on_token\", length: 6\n    t.index [\"uuid\"], name: \"index_servers_on_uuid\", length: 8\n  end\n\n  create_table \"smtp_endpoints\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"server_id\"\n    t.string \"uuid\"\n    t.string \"name\"\n    t.string \"hostname\"\n    t.string \"ssl_mode\"\n    t.integer \"port\"\n    t.text \"error\"\n    t.datetime \"disabled_until\"\n    t.datetime \"last_used_at\"\n    t.datetime \"created_at\"\n    t.datetime \"updated_at\"\n  end\n\n  create_table \"statistics\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.bigint \"total_messages\", default: 0\n    t.bigint \"total_outgoing\", default: 0\n    t.bigint \"total_incoming\", default: 0\n  end\n\n  create_table \"track_certificates\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.string \"domain\"\n    t.text \"certificate\"\n    t.text \"intermediaries\"\n    t.text \"key\"\n    t.datetime \"expires_at\", precision: nil\n    t.datetime \"renew_after\", precision: nil\n    t.string \"verification_path\"\n    t.string \"verification_string\"\n    t.datetime \"created_at\", precision: nil, null: false\n    t.datetime \"updated_at\", precision: nil, null: false\n    t.index [\"domain\"], name: \"index_track_certificates_on_domain\", length: 8\n  end\n\n  create_table \"track_domains\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.string \"uuid\"\n    t.integer \"server_id\"\n    t.integer \"domain_id\"\n    t.string \"name\"\n    t.datetime \"dns_checked_at\", precision: nil\n    t.string \"dns_status\"\n    t.string \"dns_error\"\n    t.datetime \"created_at\", precision: nil, null: false\n    t.datetime \"updated_at\", precision: nil, null: false\n    t.boolean \"ssl_enabled\", default: true\n    t.boolean \"track_clicks\", default: true\n    t.boolean \"track_loads\", default: true\n    t.text \"excluded_click_domains\"\n  end\n\n  create_table \"user_invites\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.string \"uuid\"\n    t.string \"email_address\"\n    t.datetime \"expires_at\"\n    t.datetime \"created_at\"\n    t.datetime \"updated_at\"\n    t.index [\"uuid\"], name: \"index_user_invites_on_uuid\", length: 12\n  end\n\n  create_table \"users\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.string \"uuid\"\n    t.string \"first_name\"\n    t.string \"last_name\"\n    t.string \"email_address\"\n    t.string \"password_digest\"\n    t.string \"time_zone\"\n    t.string \"email_verification_token\"\n    t.datetime \"email_verified_at\", precision: nil\n    t.datetime \"created_at\"\n    t.datetime \"updated_at\"\n    t.string \"password_reset_token\"\n    t.datetime \"password_reset_token_valid_until\", precision: nil\n    t.boolean \"admin\", default: false\n    t.string \"oidc_uid\"\n    t.string \"oidc_issuer\"\n    t.index [\"email_address\"], name: \"index_users_on_email_address\", length: 8\n    t.index [\"uuid\"], name: \"index_users_on_uuid\", length: 8\n  end\n\n  create_table \"webhook_events\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"webhook_id\"\n    t.string \"event\"\n    t.datetime \"created_at\"\n    t.index [\"webhook_id\"], name: \"index_webhook_events_on_webhook_id\"\n  end\n\n  create_table \"webhook_requests\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"server_id\"\n    t.integer \"webhook_id\"\n    t.string \"url\"\n    t.string \"event\"\n    t.string \"uuid\"\n    t.text \"payload\"\n    t.integer \"attempts\", default: 0\n    t.datetime \"retry_after\"\n    t.text \"error\"\n    t.datetime \"created_at\"\n    t.string \"locked_by\"\n    t.datetime \"locked_at\", precision: nil\n    t.index [\"locked_by\"], name: \"index_webhook_requests_on_locked_by\"\n  end\n\n  create_table \"webhooks\", id: :integer, charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.integer \"server_id\"\n    t.string \"uuid\"\n    t.string \"name\"\n    t.string \"url\"\n    t.datetime \"last_used_at\", precision: nil\n    t.boolean \"all_events\", default: false\n    t.boolean \"enabled\", default: true\n    t.boolean \"sign\", default: true\n    t.datetime \"created_at\"\n    t.datetime \"updated_at\"\n    t.index [\"server_id\"], name: \"index_webhooks_on_server_id\"\n  end\n\n  create_table \"worker_roles\", charset: \"utf8mb4\", collation: \"utf8mb4_general_ci\", force: :cascade do |t|\n    t.string \"role\"\n    t.string \"worker\"\n    t.datetime \"acquired_at\", precision: nil\n    t.index [\"role\"], name: \"index_worker_roles_on_role\", unique: true\n  end\n\nend\n"
  },
  {
    "path": "db/seeds.rb",
    "content": "# frozen_string_literal: true\n# Seeds go here...\n"
  },
  {
    "path": "doc/config/configuration.md",
    "content": "# Configuring Postal\n\nPostal can be configured in two ways: using a YAML-based configuration file or through environment variables.\n\nIf you choose to use environment variables, you don't need to provide a config file. A full list of environment variables is available in the `environment-variables.md` file in this directory. \n\nTo use a configuration file, the `POSTAL_CONFIG_FILE_PATH` environment variable will dictate where Postal will look for the config file. An example YAML file containing all available configuration is provided in the `yaml.yml` file in this directory. Remember to include the `version: 2` key/value in your configuration file.\n\n## Development \n\nWhen developing with Postal, you can configure the application by placing a configuration file in `config/postal/postal.yml`. Alternatively, you can use environment variables by placing configuration in `.env` in the root of the application.\n\n### Running tests\n\nBy default, tests will use the `config/postal/postal.test.yml` configuration file and the `.env.test` environment file.\n\n## Containers\n\nWithin a container, Postal will for a config file in `/config/postal.yml` unless overriden by the `POSTAL_CONFIG_FILE_PATH` environment variable.\n\n## Ports & Bind Addresses\n\nThe web & SMTP server listen on ports and addresses. The defaults for these can be set through configuration however, if you're running multiple instances of these on a single host you will need to specify different ports for each one.\n\nYou can use the `PORT` and `BIND_ADDRESS` environment variables to provide instance-specific values for these processes.\n\nAdditionally, `HEALTH_SERVER_PORT` and `HEALTH_SERVER_BIND_ADDRESS`  can be used to set the port/address to use for running the health server alongside other processes.\n\n## Legacy configuration\n\nLegacy configuration files from Postal v1 and v2 are still supported. If you wish to use a new configuration option that is not available in the legacy format, you will need to upgrade the file to version 2.\n"
  },
  {
    "path": "doc/config/environment-variables.md",
    "content": "# Environment Variables\n\nThis document contains all the environment variables which are available for this application.\n\n| Name | Type | Description | Default |\n| ---- | ---- | ----------- | ------- |\n| `POSTAL_WEB_HOSTNAME` | String | The hostname that the Postal web interface runs on | postal.example.com |\n| `POSTAL_WEB_PROTOCOL` | String | The HTTP protocol to use for the Postal web interface | https |\n| `POSTAL_SMTP_HOSTNAME` | String | The hostname that the Postal SMTP server runs on | postal.example.com |\n| `POSTAL_USE_IP_POOLS` | Boolean | Should IP pools be enabled for this installation? | false |\n| `POSTAL_DEFAULT_MAXIMUM_DELIVERY_ATTEMPTS` | Integer | The maximum number of delivery attempts | 18 |\n| `POSTAL_DEFAULT_MAXIMUM_HOLD_EXPIRY_DAYS` | Integer | The number of days to hold a message before they will be expired | 7 |\n| `POSTAL_DEFAULT_SUPPRESSION_LIST_AUTOMATIC_REMOVAL_DAYS` | Integer | The number of days an address will remain in a suppression list before being removed | 30 |\n| `POSTAL_DEFAULT_SPAM_THRESHOLD` | Integer | The default threshold at which a message should be treated as spam | 5 |\n| `POSTAL_DEFAULT_SPAM_FAILURE_THRESHOLD` | Integer | The default threshold at which a message should be treated as spam failure | 20 |\n| `POSTAL_USE_LOCAL_NS_FOR_DOMAIN_VERIFICATION` | Boolean | Domain verification and checking usually checks with a domain's nameserver. Enable this to check with the server's local nameservers. | false |\n| `POSTAL_USE_RESENT_SENDER_HEADER` | Boolean | Append a Resend-Sender header to all outgoing e-mails | true |\n| `POSTAL_SIGNING_KEY_PATH` | String | Path to the private key used for signing | $config-file-root/signing.key |\n| `POSTAL_SMTP_RELAYS` | Array of strings | An array of SMTP relays in the format of smtp://host:port | [] |\n| `POSTAL_TRUSTED_PROXIES` | Array of strings | An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses) | [] |\n| `POSTAL_QUEUED_MESSAGE_LOCK_STALE_DAYS` | Integer | The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried. | 1 |\n| `POSTAL_BATCH_QUEUED_MESSAGES` | Boolean | When enabled queued messages will be de-queued in batches based on their destination | true |\n| `WEB_SERVER_DEFAULT_PORT` | Integer | The default port the web server should listen on unless overriden by the PORT environment variable | 5000 |\n| `WEB_SERVER_DEFAULT_BIND_ADDRESS` | String | The default bind address the web server should listen on unless overriden by the BIND_ADDRESS environment variable | 127.0.0.1 |\n| `WEB_SERVER_MAX_THREADS` | Integer | The maximum number of threads which can be used by the web server | 5 |\n| `WORKER_DEFAULT_HEALTH_SERVER_PORT` | Integer | The default port for the worker health server to listen on | 9090 |\n| `WORKER_DEFAULT_HEALTH_SERVER_BIND_ADDRESS` | String | The default bind address for the worker health server to listen on | 127.0.0.1 |\n| `WORKER_THREADS` | Integer | The number of threads to execute within each worker | 2 |\n| `MAIN_DB_HOST` | String | Hostname for the main MariaDB server | localhost |\n| `MAIN_DB_PORT` | Integer | The MariaDB port to connect to | 3306 |\n| `MAIN_DB_USERNAME` | String | The MariaDB username | postal |\n| `MAIN_DB_PASSWORD` | String | The MariaDB password |  |\n| `MAIN_DB_DATABASE` | String | The MariaDB database name | postal |\n| `MAIN_DB_POOL_SIZE` | Integer | The maximum size of the MariaDB connection pool | 5 |\n| `MAIN_DB_ENCODING` | String | The encoding to use when connecting to the MariaDB database | utf8mb4 |\n| `MESSAGE_DB_HOST` | String | Hostname for the MariaDB server which stores the mail server databases | localhost |\n| `MESSAGE_DB_PORT` | Integer | The MariaDB port to connect to | 3306 |\n| `MESSAGE_DB_USERNAME` | String | The MariaDB username | postal |\n| `MESSAGE_DB_PASSWORD` | String | The MariaDB password |  |\n| `MESSAGE_DB_ENCODING` | String | The encoding to use when connecting to the MariaDB database | utf8mb4 |\n| `MESSAGE_DB_DATABASE_NAME_PREFIX` | String | The MariaDB prefix to add to database names | postal |\n| `LOGGING_RAILS_LOG_ENABLED` | Boolean | Enable the default Rails logger | false |\n| `LOGGING_SENTRY_DSN` | String | A DSN which should be used to report exceptions to Sentry |  |\n| `LOGGING_ENABLED` | Boolean | Enable the Postal logger to log to STDOUT | true |\n| `LOGGING_HIGHLIGHTING_ENABLED` | Boolean | Enable highlighting of log lines | false |\n| `GELF_HOST` | String | GELF-capable host to send logs to |  |\n| `GELF_PORT` | Integer | GELF port to send logs to | 12201 |\n| `GELF_FACILITY` | String | The facility name to add to all log entries sent to GELF | postal |\n| `SMTP_SERVER_DEFAULT_PORT` | Integer | The default port the SMTP server should listen on unless overriden by the PORT environment variable | 25 |\n| `SMTP_SERVER_DEFAULT_BIND_ADDRESS` | String | The default bind address the SMTP server should listen on unless overriden by the BIND_ADDRESS environment variable | :: |\n| `SMTP_SERVER_DEFAULT_HEALTH_SERVER_PORT` | Integer | The default port for the SMTP server health server to listen on | 9091 |\n| `SMTP_SERVER_DEFAULT_HEALTH_SERVER_BIND_ADDRESS` | String | The default bind address for the SMTP server health server to listen on | 127.0.0.1 |\n| `SMTP_SERVER_TLS_ENABLED` | Boolean | Enable TLS for the SMTP server (requires certificate) | false |\n| `SMTP_SERVER_TLS_CERTIFICATE_PATH` | String | The path to the SMTP server's TLS certificate | $config-file-root/smtp.cert |\n| `SMTP_SERVER_TLS_PRIVATE_KEY_PATH` | String | The path to the SMTP server's TLS private key | $config-file-root/smtp.key |\n| `SMTP_SERVER_TLS_CIPHERS` | String | Override ciphers to use for SSL |  |\n| `SMTP_SERVER_SSL_VERSION` | String | The SSL versions which are supported | SSLv23 |\n| `SMTP_SERVER_PROXY_PROTOCOL` | Boolean | Enable proxy protocol for use behind some load balancers (supports proxy protocol v1 only) | false |\n| `SMTP_SERVER_LOG_CONNECTIONS` | Boolean | Enable connection logging | false |\n| `SMTP_SERVER_MAX_MESSAGE_SIZE` | Integer | The maximum message size to accept from the SMTP server (in MB) | 14 |\n| `SMTP_SERVER_LOG_IP_ADDRESS_EXCLUSION_MATCHER` | String | A regular expression to use to exclude connections from logging |  |\n| `DNS_MX_RECORDS` | Array of strings | The names of the default MX records | [\"mx1.postal.example.com\", \"mx2.postal.example.com\"] |\n| `DNS_SPF_INCLUDE` | String | The location of the SPF record | spf.postal.example.com |\n| `DNS_RETURN_PATH_DOMAIN` | String | The return path hostname | rp.postal.example.com |\n| `DNS_ROUTE_DOMAIN` | String | The domain to use for hosting route-specific addresses | routes.postal.example.com |\n| `DNS_TRACK_DOMAIN` | String | The CNAME which tracking domains should be pointed to | track.postal.example.com |\n| `DNS_HELO_HOSTNAME` | String | The hostname to use in HELO/EHLO when connecting to external SMTP servers |  |\n| `DNS_DKIM_IDENTIFIER` | String | The identifier to use for DKIM keys in DNS records | postal |\n| `DNS_DOMAIN_VERIFY_PREFIX` | String | The prefix to add before TXT record verification string | postal-verification |\n| `DNS_CUSTOM_RETURN_PATH_PREFIX` | String | The domain to use on external domains which points to the Postal return path domain | psrp |\n| `DNS_TIMEOUT` | Integer | The timeout to wait for DNS resolution | 5 |\n| `DNS_RESOLV_CONF_PATH` | String | The path to the resolv.conf file containing addresses for local nameservers | /etc/resolv.conf |\n| `SMTP_HOST` | String | The hostname to send application-level e-mails to | 127.0.0.1 |\n| `SMTP_PORT` | Integer | The port number to send application-level e-mails to | 25 |\n| `SMTP_USERNAME` | String | The username to use when authentication to the SMTP server |  |\n| `SMTP_PASSWORD` | String | The password to use when authentication to the SMTP server |  |\n| `SMTP_AUTHENTICATION_TYPE` | String | The type of authentication to use | login |\n| `SMTP_ENABLE_STARTTLS` | Boolean | Use STARTTLS when connecting to the SMTP server and fail if unsupported | false |\n| `SMTP_ENABLE_STARTTLS_AUTO` | Boolean | Detects if STARTTLS is enabled in the SMTP server and starts to use it | true |\n| `SMTP_OPENSSL_VERIFY_MODE` | String | When using TLS, you can set how OpenSSL checks the certificate. Use 'none' for no certificate checking | peer |\n| `SMTP_FROM_NAME` | String | The name to use as the from name outgoing emails from Postal | Postal |\n| `SMTP_FROM_ADDRESS` | String | The e-mail to use as the from address outgoing emails from Postal | postal@example.com |\n| `RAILS_ENVIRONMENT` | String | The Rails environment to run the application in | production |\n| `RAILS_SECRET_KEY` | String | The secret key used to sign and encrypt cookies and session data in the application |  |\n| `RSPAMD_ENABLED` | Boolean | Enable rspamd for message inspection | false |\n| `RSPAMD_HOST` | String | The hostname of the rspamd server | 127.0.0.1 |\n| `RSPAMD_PORT` | Integer | The port of the rspamd server | 11334 |\n| `RSPAMD_SSL` | Boolean | Enable SSL for the rspamd connection | false |\n| `RSPAMD_PASSWORD` | String | The password for the rspamd server |  |\n| `RSPAMD_FLAGS` | String | Any flags for the rspamd server |  |\n| `SPAMD_ENABLED` | Boolean | Enable SpamAssassin for message inspection | false |\n| `SPAMD_HOST` | String | The hostname for the SpamAssassin server | 127.0.0.1 |\n| `SPAMD_PORT` | Integer | The port of the SpamAssassin server | 783 |\n| `CLAMAV_ENABLED` | Boolean | Enable ClamAV for message inspection | false |\n| `CLAMAV_HOST` | String | The host of the ClamAV server | 127.0.0.1 |\n| `CLAMAV_PORT` | Integer | The port of the ClamAV server | 2000 |\n| `SMTP_CLIENT_OPEN_TIMEOUT` | Integer | The open timeout for outgoing SMTP connections | 30 |\n| `SMTP_CLIENT_READ_TIMEOUT` | Integer | The read timeout for outgoing SMTP connections | 30 |\n| `MIGRATION_WAITER_ENABLED` | Boolean | Wait for all migrations to run before starting a process | false |\n| `MIGRATION_WAITER_ATTEMPTS` | Integer | The number of attempts to try waiting for migrations to complete before start | 120 |\n| `MIGRATION_WAITER_SLEEP_TIME` | Integer | The number of seconds to wait between each migration check | 2 |\n| `OIDC_ENABLED` | Boolean | Enable OIDC authentication | false |\n| `OIDC_LOCAL_AUTHENTICATION_ENABLED` | Boolean | When enabled, users with passwords will still be able to login locally. If disable, only OpenID Connect will be available. | true |\n| `OIDC_NAME` | String | The name of the OIDC provider as shown in the UI | OIDC Provider |\n| `OIDC_ISSUER` | String | The OIDC issuer URL |  |\n| `OIDC_IDENTIFIER` | String | The client ID for OIDC |  |\n| `OIDC_SECRET` | String | The client secret for OIDC |  |\n| `OIDC_SCOPES` | Array of strings | Scopes to request from the OIDC server. | [\"openid\", \"email\"] |\n| `OIDC_UID_FIELD` | String | The field to use to determine the user's UID | sub |\n| `OIDC_EMAIL_ADDRESS_FIELD` | String | The field to use to determine the user's email address | email |\n| `OIDC_NAME_FIELD` | String | The field to use to determine the user's name | name |\n| `OIDC_DISCOVERY` | Boolean | Enable discovery to determine endpoints from .well-known/openid-configuration from the Issuer | true |\n| `OIDC_AUTHORIZATION_ENDPOINT` | String | The authorize endpoint on the authorization server (only used when discovery is false) |  |\n| `OIDC_TOKEN_ENDPOINT` | String | The token endpoint on the authorization server (only used when discovery is false) |  |\n| `OIDC_USERINFO_ENDPOINT` | String | The user info endpoint on the authorization server (only used when discovery is false) |  |\n| `OIDC_JWKS_URI` | String | The JWKS endpoint on the authorization server (only used when discovery is false) |  |\n"
  },
  {
    "path": "doc/config/yaml.yml",
    "content": "version: 2\n\npostal:\n  # The hostname that the Postal web interface runs on\n  web_hostname: postal.example.com\n  # The HTTP protocol to use for the Postal web interface\n  web_protocol: https\n  # The hostname that the Postal SMTP server runs on\n  smtp_hostname: postal.example.com\n  # Should IP pools be enabled for this installation?\n  use_ip_pools: false\n  # The maximum number of delivery attempts\n  default_maximum_delivery_attempts: 18\n  # The number of days to hold a message before they will be expired\n  default_maximum_hold_expiry_days: 7\n  # The number of days an address will remain in a suppression list before being removed\n  default_suppression_list_automatic_removal_days: 30\n  # The default threshold at which a message should be treated as spam\n  default_spam_threshold: 5\n  # The default threshold at which a message should be treated as spam failure\n  default_spam_failure_threshold: 20\n  # Domain verification and checking usually checks with a domain's nameserver. Enable this to check with the server's local nameservers.\n  use_local_ns_for_domain_verification: false\n  # Append a Resend-Sender header to all outgoing e-mails\n  use_resent_sender_header: true\n  # Path to the private key used for signing\n  signing_key_path: $config-file-root/signing.key\n  # An array of SMTP relays in the format of smtp://host:port\n  smtp_relays: []\n  # An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses)\n  trusted_proxies: []\n  # The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried.\n  queued_message_lock_stale_days: 1\n  # When enabled queued messages will be de-queued in batches based on their destination\n  batch_queued_messages: true\n\nweb_server:\n  # The default port the web server should listen on unless overriden by the PORT environment variable\n  default_port: 5000\n  # The default bind address the web server should listen on unless overriden by the BIND_ADDRESS environment variable\n  default_bind_address: 127.0.0.1\n  # The maximum number of threads which can be used by the web server\n  max_threads: 5\n\nworker:\n  # The default port for the worker health server to listen on\n  default_health_server_port: 9090\n  # The default bind address for the worker health server to listen on\n  default_health_server_bind_address: 127.0.0.1\n  # The number of threads to execute within each worker\n  threads: 2\n\nmain_db:\n  # Hostname for the main MariaDB server\n  host: localhost\n  # The MariaDB port to connect to\n  port: 3306\n  # The MariaDB username\n  username: postal\n  # The MariaDB password\n  password: \n  # The MariaDB database name\n  database: postal\n  # The maximum size of the MariaDB connection pool\n  pool_size: 5\n  # The encoding to use when connecting to the MariaDB database\n  encoding: utf8mb4\n\nmessage_db:\n  # Hostname for the MariaDB server which stores the mail server databases\n  host: localhost\n  # The MariaDB port to connect to\n  port: 3306\n  # The MariaDB username\n  username: postal\n  # The MariaDB password\n  password: \n  # The encoding to use when connecting to the MariaDB database\n  encoding: utf8mb4\n  # The MariaDB prefix to add to database names\n  database_name_prefix: postal\n\nlogging:\n  # Enable the default Rails logger\n  rails_log_enabled: false\n  # A DSN which should be used to report exceptions to Sentry\n  sentry_dsn: \n  # Enable the Postal logger to log to STDOUT\n  enabled: true\n  # Enable highlighting of log lines\n  highlighting_enabled: false\n\ngelf:\n  # GELF-capable host to send logs to\n  host: \n  # GELF port to send logs to\n  port: 12201\n  # The facility name to add to all log entries sent to GELF\n  facility: postal\n\nsmtp_server:\n  # The default port the SMTP server should listen on unless overriden by the PORT environment variable\n  default_port: 25\n  # The default bind address the SMTP server should listen on unless overriden by the BIND_ADDRESS environment variable\n  default_bind_address: ::\n  # The default port for the SMTP server health server to listen on\n  default_health_server_port: 9091\n  # The default bind address for the SMTP server health server to listen on\n  default_health_server_bind_address: 127.0.0.1\n  # Enable TLS for the SMTP server (requires certificate)\n  tls_enabled: false\n  # The path to the SMTP server's TLS certificate\n  tls_certificate_path: $config-file-root/smtp.cert\n  # The path to the SMTP server's TLS private key\n  tls_private_key_path: $config-file-root/smtp.key\n  # Override ciphers to use for SSL\n  tls_ciphers: \n  # The SSL versions which are supported\n  ssl_version: SSLv23\n  # Enable proxy protocol for use behind some load balancers (supports proxy protocol v1 only)\n  proxy_protocol: false\n  # Enable connection logging\n  log_connections: false\n  # The maximum message size to accept from the SMTP server (in MB)\n  max_message_size: 14\n  # A regular expression to use to exclude connections from logging\n  log_ip_address_exclusion_matcher: \n\ndns:\n  # The names of the default MX records\n  mx_records:\n    - mx1.postal.example.com\n    - mx2.postal.example.com\n  # The location of the SPF record\n  spf_include: spf.postal.example.com\n  # The return path hostname\n  return_path_domain: rp.postal.example.com\n  # The domain to use for hosting route-specific addresses\n  route_domain: routes.postal.example.com\n  # The CNAME which tracking domains should be pointed to\n  track_domain: track.postal.example.com\n  # The hostname to use in HELO/EHLO when connecting to external SMTP servers\n  helo_hostname: \n  # The identifier to use for DKIM keys in DNS records\n  dkim_identifier: postal\n  # The prefix to add before TXT record verification string\n  domain_verify_prefix: postal-verification\n  # The domain to use on external domains which points to the Postal return path domain\n  custom_return_path_prefix: psrp\n  # The timeout to wait for DNS resolution\n  timeout: 5\n  # The path to the resolv.conf file containing addresses for local nameservers\n  resolv_conf_path: /etc/resolv.conf\n\nsmtp:\n  # The hostname to send application-level e-mails to\n  host: 127.0.0.1\n  # The port number to send application-level e-mails to\n  port: 25\n  # The username to use when authentication to the SMTP server\n  username: \n  # The password to use when authentication to the SMTP server\n  password: \n  # The type of authentication to use\n  authentication_type: login\n  # Use STARTTLS when connecting to the SMTP server and fail if unsupported\n  enable_starttls: false\n  # Detects if STARTTLS is enabled in the SMTP server and starts to use it\n  enable_starttls_auto: true\n  # When using TLS, you can set how OpenSSL checks the certificate. Use 'none' for no certificate checking\n  openssl_verify_mode: peer\n  # The name to use as the from name outgoing emails from Postal\n  from_name: Postal\n  # The e-mail to use as the from address outgoing emails from Postal\n  from_address: postal@example.com\n\nrails:\n  # The Rails environment to run the application in\n  environment: production\n  # The secret key used to sign and encrypt cookies and session data in the application\n  secret_key: \n\nrspamd:\n  # Enable rspamd for message inspection\n  enabled: false\n  # The hostname of the rspamd server\n  host: 127.0.0.1\n  # The port of the rspamd server\n  port: 11334\n  # Enable SSL for the rspamd connection\n  ssl: false\n  # The password for the rspamd server\n  password: \n  # Any flags for the rspamd server\n  flags: \n\nspamd:\n  # Enable SpamAssassin for message inspection\n  enabled: false\n  # The hostname for the SpamAssassin server\n  host: 127.0.0.1\n  # The port of the SpamAssassin server\n  port: 783\n\nclamav:\n  # Enable ClamAV for message inspection\n  enabled: false\n  # The host of the ClamAV server\n  host: 127.0.0.1\n  # The port of the ClamAV server\n  port: 2000\n\nsmtp_client:\n  # The open timeout for outgoing SMTP connections\n  open_timeout: 30\n  # The read timeout for outgoing SMTP connections\n  read_timeout: 30\n\nmigration_waiter:\n  # Wait for all migrations to run before starting a process\n  enabled: false\n  # The number of attempts to try waiting for migrations to complete before start\n  attempts: 120\n  # The number of seconds to wait between each migration check\n  sleep_time: 2\n\noidc:\n  # Enable OIDC authentication\n  enabled: false\n  # When enabled, users with passwords will still be able to login locally. If disable, only OpenID Connect will be available.\n  local_authentication_enabled: true\n  # The name of the OIDC provider as shown in the UI\n  name: OIDC Provider\n  # The OIDC issuer URL\n  issuer: \n  # The client ID for OIDC\n  identifier: \n  # The client secret for OIDC\n  secret: \n  # Scopes to request from the OIDC server.\n  scopes:\n    - openid\n    - email\n  # The field to use to determine the user's UID\n  uid_field: sub\n  # The field to use to determine the user's email address\n  email_address_field: email\n  # The field to use to determine the user's name\n  name_field: name\n  # Enable discovery to determine endpoints from .well-known/openid-configuration from the Issuer\n  discovery: true\n  # The authorize endpoint on the authorization server (only used when discovery is false)\n  authorization_endpoint: \n  # The token endpoint on the authorization server (only used when discovery is false)\n  token_endpoint: \n  # The user info endpoint on the authorization server (only used when discovery is false)\n  userinfo_endpoint: \n  # The JWKS endpoint on the authorization server (only used when discovery is false)\n  jwks_uri: \n"
  },
  {
    "path": "docker/ci-config/signing.key",
    "content": "-----BEGIN RSA PRIVATE KEY-----\nMIICXQIBAAKBgQDWt+8XMJEoeOd5DW0ETcj7oklUiyd39h71ZsBxlZSOSydsb+ft\n26C/dfJmT/1W38oxhct1iuRN6ETNHNeLtwrysxhpmSiea/eU8Iv3s4WVgdTJyneT\nH++yuXwOyEmb1opc7igSRfbFLkeVcYm45rnzbjf/26UaFwmtZlxPI0LZrQIDAQAB\nAoGAA8cOpMjM9PpTkDSlQ1se+xZa1erw0dJ5rvWU0yq/h1VZJzY8zVl81YF8t0IX\nAe1EAGULNFEyPRCmDTnBrQqWXbB5bqZIROKT+1Ruo1Kg6OUsrSDK3cDWYM2lInB/\n/CXKo4pv82Zh9iS9qj7URFOEzEX43KNXCrfrLSOVRp+xnB0CQQDzxOhujaSqGlXW\nwLdrTPRWp63SNbkhWK8FXnkeoyH02EvlWSIGQL2deDaOQk9VKd7kCes9MyWC9d7L\nFO/e7GdDAkEA4X3ieo3Wted69PG3c+jN//nA1WZdgHKucGkOTk1MakqG6kf7DeVt\nUj2AFR8wxtKDYi9G+YxdKb+lI/T0CI9UTwJBALwf3UTcWRTRiCdYyPStCfAKLbIJ\ntdrPRxr8orqLKPx9JG1WEVUEB5GMIYY+FF1kF9ii8wFjBHMB7rOJb+j5RmMCQA27\nU8pwzs1/Dj7SZYCagcj/1Z1pQXJsCXFxBF0CWg/y/+pOfdxnx1OFyUIAB0FkWnnl\nNSZHRPkg4Zah+SZ4TAMCQQDid1Cp6nhlntpOANWIXxkJJuDVYepQN1AgCvwZ3OMl\njOfmHNFkXlzb4EYr+uoab2cXV3TNVtau7Z5/Se1ZTVSp\n-----END RSA PRIVATE KEY-----\n"
  },
  {
    "path": "docker/wait-for.sh",
    "content": "#!/bin/sh\n[ -n \"$DEBUG\" ] && set -x\n\ncheck_http() {\n  wget -T 1 -S -q -O - \"$1\" 2>&1 | head -1 |\n    head -1 | grep -E 'HTTP.+\\s2\\d{2}' >/dev/null 2>&1\n  return $?\n}\n\ncheck_tcp() {\n  host=\"$(echo \"$1\" | cut -d: -f1)\"\n  port=\"$(echo \"$1\" | cut -d: -f2)\"\n  if [ -z \"${host}\" ] || [ -z \"${port}\" ]; then\n    echo \"TCP target ${1} is not in \\\"<host>:<port>\\\" format\" >&2\n    exit 2\n  fi\n\n  nc -z -w1 \"$host\" \"$port\" >/dev/null 2>&1\n  return $?\n}\n\nwait_for() {\n  type=\"$1\"\n  uri=\"$2\"\n  timeout=\"${3:-30}\"\n\n  seconds=0\n  while [ \"$seconds\" -lt \"$timeout\" ] && ! \"check_${type}\" \"$uri\"; do\n    if [ \"$seconds\" -lt \"1\" ]; then\n      printf \"Waiting for %s  .\" \"$uri\"\n    else\n      printf .\n    fi\n    seconds=$((seconds + 1))\n    sleep 1\n  done\n\n  if [ \"$seconds\" -lt \"$timeout\" ]; then\n    if [ \"$seconds\" -gt \"0\" ]; then\n      echo \"  up!\"\n    fi\n  else\n    echo \"  FAIL\"\n    echo \"ERROR: unable to connect to: $uri\" >&2\n    exit 1\n  fi\n}\n\nif [ -n \"$WAIT_FOR_TARGETS\" ]; then\n  uris=\"$(echo \"$WAIT_FOR_TARGETS\" | sed -e 's/\\s+/\\n/g' | uniq)\"\n  for uri in $uris; do\n    if echo \"$uri\" | grep -E '^https?://.*' >/dev/null 2>&1; then\n      wait_for \"http\" \"$uri\" \"$WAIT_FOR_TIMEOUT\"\n    else\n      wait_for \"tcp\" \"$uri\" \"$WAIT_FOR_TIMEOUT\"\n    fi\n  done\nfi\n\nexec \"$@\"\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  postal:\n    image: ${POSTAL_IMAGE}\n    depends_on:\n      - mariadb\n    entrypoint: [\"/docker-entrypoint.sh\"]\n    volumes:\n      - \"./docker/ci-config:/config\"\n    environment:\n      POSTAL_SIGNING_KEY_PATH: /config/signing.key\n      MAIN_DB_HOST: mariadb\n      MAIN_DB_USERNAME: root\n      MESSAGE_DB_HOST: mariadb\n      MESSAGE_DB_USERNAME: root\n      LOGGING_ENABLED: \"false\"\n      RAILS_ENVIRONMENT: test\n      RAILS_LOG_ENABLED: \"false\"\n      WAIT_FOR_TIMEOUT: 90\n      WAIT_FOR_TARGETS: |-\n        mariadb:3306\n\n  mariadb:\n    image: mariadb\n    restart: always\n    environment:\n      MARIADB_DATABASE: postal\n      MARIADB_ALLOW_EMPTY_PASSWORD: 'yes'\n      MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 'yes'\n"
  },
  {
    "path": "lib/assets/.keep",
    "content": ""
  },
  {
    "path": "lib/migration_waiter.rb",
    "content": "# frozen_string_literal: true\n\n# This initializer will wait for all pending migrations to be applied before\n# continuing to start the application. This is useful when running the application\n# in a cluster where migrations are run in a separate job which runs at the same\n# time as the other processes.\n\nclass MigrationWaiter\n\n  ATTEMPTS = Postal::Config.migration_waiter.attempts\n  SLEEP_TIME = Postal::Config.migration_waiter.sleep_time\n\n  class << self\n\n    def wait\n      attempts_remaining = ATTEMPTS\n      loop do\n        pending_migrations = ActiveRecord::Base.connection.migration_context.open.pending_migrations.size\n        if pending_migrations.zero?\n          Postal.logger.info \"no pending migrations, continuing\"\n          return\n        end\n\n        attempts_remaining -= 1\n\n        if attempts_remaining.zero?\n          Postal.logger.info \"#{pending_migrations} migration(s) are still pending after #{ATTEMPTS} attempts, exiting\"\n          Process.exit(1)\n        else\n          Postal.logger.info \"waiting for #{pending_migrations} migration(s) to be applied (#{attempts_remaining} remaining)\"\n          sleep SLEEP_TIME\n        end\n      end\n    end\n\n    def wait_if_appropriate\n      # Don't wait if not configured\n      return unless Postal::Config.migration_waiter.enabled?\n\n      # Don't wait in the console, rake tasks or rails commands\n      return if console? || rake_task? || rails_command?\n\n      wait\n    end\n\n    def console?\n      Rails.const_defined?(\"Console\")\n    end\n\n    def rake_task?\n      Rake.application.top_level_tasks.any?\n    end\n\n    def rails_command?\n      caller.any? { |c| c =~ /rails\\/commands/ }\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "lib/postal/config.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"erb\"\nrequire \"yaml\"\nrequire \"pathname\"\nrequire \"cgi\"\nrequire \"openssl\"\nrequire \"fileutils\"\nrequire \"konfig\"\nrequire \"konfig/sources/environment\"\nrequire \"konfig/sources/yaml\"\nrequire \"dotenv\"\nrequire \"klogger\"\n\nrequire_relative \"error\"\nrequire_relative \"config_schema\"\nrequire_relative \"legacy_config_source\"\nrequire_relative \"signer\"\n\nmodule Postal\n\n  class << self\n\n    attr_writer :current_process_type\n\n    # Return the path to the config file\n    #\n    # @return [String]\n    def config_file_path\n      ENV.fetch(\"POSTAL_CONFIG_FILE_PATH\", \"config/postal/postal.yml\")\n    end\n\n    def initialize_config\n      sources = []\n\n      # Load environment variables to begin with. Any config provided\n      # by an environment variable will override any provided in the\n      # config file.\n      Dotenv.load(\".env\")\n      sources << Konfig::Sources::Environment.new(ENV)\n\n      silence_config_messages = ENV.fetch(\"SILENCE_POSTAL_CONFIG_MESSAGES\", \"false\") == \"true\"\n\n      # If a config file exists, we need to load that. Config files can\n      # either be legacy (v1) or new (v2). Any file without a 'version'\n      # key is a legacy file whereas new-style config files will include\n      # the 'version: 2' key/value.\n      if File.file?(config_file_path)\n        unless silence_config_messages\n          warn \"Loading config from #{config_file_path}\"\n        end\n\n        config_file = File.read(config_file_path)\n        yaml = YAML.safe_load(config_file)\n        config_version = yaml[\"version\"] || 1\n        case config_version\n        when 1\n          unless silence_config_messages\n            warn \"WARNING: Using legacy config file format. Upgrade your postal.yml to use\"\n            warn \"version 2 of the Postal configuration or configure using environment\"\n            warn \"variables. See https://docs.postalserver.io/config-v2 for details.\"\n          end\n          sources << LegacyConfigSource.new(yaml)\n        when 2\n          sources << Konfig::Sources::YAML.new(config_file)\n        else\n          raise \"Invalid version specified in Postal config file. Must be 1 or 2.\"\n        end\n      elsif !silence_config_messages\n        warn \"No configuration file found at #{config_file_path}\"\n        warn \"Only using environment variables for configuration\"\n      end\n\n      # Build configuration with the provided sources.\n      Konfig::Config.build(ConfigSchema, sources: sources)\n    end\n\n    def host_with_protocol\n      @host_with_protocol ||= \"#{Config.postal.web_protocol}://#{Config.postal.web_hostname}\"\n    end\n\n    def logger\n      @logger ||= begin\n        k = Klogger.new(nil, destination: Config.logging.enabled? ? $stdout : \"/dev/null\", highlight: Config.logging.highlighting_enabled?)\n        k.add_destination(graylog_logging_destination) if Config.gelf.host.present?\n        k\n      end\n    end\n\n    def process_name\n      @process_name ||= begin\n        \"host:#{Socket.gethostname} pid:#{Process.pid}\"\n      rescue StandardError\n        \"pid:#{Process.pid}\"\n      end\n    end\n\n    def locker_name\n      string = process_name.dup\n      string += \" job:#{Thread.current[:job_id]}\" if Thread.current[:job_id]\n      string += \" thread:#{Thread.current.native_thread_id}\"\n      string\n    end\n\n    def locker_name_with_suffix(suffix)\n      \"#{locker_name} #{suffix}\"\n    end\n\n    def signer\n      @signer ||= begin\n        key = OpenSSL::PKey::RSA.new(File.read(Config.postal.signing_key_path))\n        Signer.new(key)\n      end\n    end\n\n    def rp_dkim_dns_record\n      public_key = signer.private_key.public_key.to_s.gsub(/-+[A-Z ]+-+\\n/, \"\").gsub(/\\n/, \"\")\n      \"v=DKIM1; t=s; h=sha256; p=#{public_key};\"\n    end\n\n    def ip_pools?\n      Config.postal.use_ip_pools?\n    end\n\n    def graylog_logging_destination\n      @graylog_logging_destination ||= begin\n        notifier = GELF::Notifier.new(Config.gelf.host, Config.gelf.port, \"WAN\")\n        proc do |_logger, payload, group_ids|\n          short_message = payload.delete(:message) || \"[message missing]\"\n          notifier.notify!(short_message: short_message, **{\n            facility: Config.gelf.facility,\n            _environment: Config.rails.environment,\n            _version: Postal.version.to_s,\n            _group_ids: group_ids.join(\" \")\n          }.merge(payload.transform_keys { |k| \"_#{k}\".to_sym }.transform_values(&:to_s)))\n        end\n      end\n    end\n\n    # Change the connection pool size to the given size.\n    #\n    # @param new_size [Integer]\n    # @return [void]\n    def change_database_connection_pool_size(new_size)\n      ActiveRecord::Base.connection_pool.disconnect!\n\n      config = ActiveRecord::Base.configurations\n                                 .configs_for(env_name: Config.rails.environment)\n                                 .first\n                                 .configuration_hash\n\n      ActiveRecord::Base.establish_connection(config.merge(pool: new_size))\n    end\n\n    # Return the branch name which created this release\n    #\n    # @return [String, nil]\n    def branch\n      return @branch if instance_variable_defined?(\"@branch\")\n\n      @branch ||= read_version_file(\"BRANCH\")\n    end\n\n    # Return the version\n    #\n    # @return [String, nil]\n    def version\n      return @version if instance_variable_defined?(\"@version\")\n\n      @version ||= read_version_file(\"VERSION\") || \"0.0.0\"\n    end\n\n    private\n\n    def read_version_file(file)\n      path = File.expand_path(\"../../../\" + file, __FILE__)\n      return unless File.exist?(path)\n\n      value = File.read(path).strip\n      value.empty? ? nil : value\n    end\n\n  end\n\n  Config = initialize_config\n\nend\n"
  },
  {
    "path": "lib/postal/config_schema.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"uri\"\n\nmodule Postal\n\n  # REMEMBER: If you change the schema, remember to regenerate the configuration docs\n  # using the rake command below:\n  #\n  #     rake postal:generate_config_docs\n\n  ConfigSchema = Konfig::Schema.draw do\n    group :postal do\n      string :web_hostname do\n        description \"The hostname that the Postal web interface runs on\"\n        default \"postal.example.com\"\n      end\n\n      string :web_protocol do\n        description \"The HTTP protocol to use for the Postal web interface\"\n        default \"https\"\n      end\n\n      string :smtp_hostname do\n        description \"The hostname that the Postal SMTP server runs on\"\n        default \"postal.example.com\"\n      end\n\n      boolean :use_ip_pools do\n        description \"Should IP pools be enabled for this installation?\"\n        default false\n      end\n\n      integer :default_maximum_delivery_attempts do\n        description \"The maximum number of delivery attempts\"\n        default 18\n      end\n\n      integer :default_maximum_hold_expiry_days do\n        description \"The number of days to hold a message before they will be expired\"\n        default 7\n      end\n\n      integer :default_suppression_list_automatic_removal_days do\n        description \"The number of days an address will remain in a suppression list before being removed\"\n        default 30\n      end\n\n      integer :default_spam_threshold do\n        description \"The default threshold at which a message should be treated as spam\"\n        default 5\n      end\n\n      integer :default_spam_failure_threshold do\n        description \"The default threshold at which a message should be treated as spam failure\"\n        default 20\n      end\n\n      boolean :use_local_ns_for_domain_verification do\n        description \"Domain verification and checking usually checks with a domain's nameserver. Enable this to check with the server's local nameservers.\"\n        default false\n      end\n\n      boolean :use_resent_sender_header do\n        description \"Append a Resend-Sender header to all outgoing e-mails\"\n        default true\n      end\n\n      string :signing_key_path do\n        description \"Path to the private key used for signing\"\n        default \"$config-file-root/signing.key\"\n        transform { |v| Postal.substitute_config_file_root(v) }\n      end\n\n      string :smtp_relays do\n        array\n        description \"An array of SMTP relays in the format of smtp://host:port\"\n        transform do |value|\n          uri = URI.parse(value)\n          query = uri.query ? CGI.parse(uri.query) : {}\n          {\n            host: uri.host,\n            port: uri.port || 25,\n            ssl_mode: query[\"ssl_mode\"]&.first || \"Auto\"\n          }\n        end\n      end\n\n      string :trusted_proxies do\n        array\n        description \"An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses)\"\n        transform { |ip| IPAddr.new(ip) }\n      end\n\n      integer :queued_message_lock_stale_days do\n        description \"The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried.\"\n        default 1\n      end\n\n      boolean :batch_queued_messages do\n        description \"When enabled queued messages will be de-queued in batches based on their destination\"\n        default true\n      end\n    end\n\n    group :web_server do\n      integer :default_port do\n        description \"The default port the web server should listen on unless overriden by the PORT environment variable\"\n        default 5000\n      end\n\n      string :default_bind_address do\n        description \"The default bind address the web server should listen on unless overriden by the BIND_ADDRESS environment variable\"\n        default \"127.0.0.1\"\n      end\n\n      integer :max_threads do\n        description \"The maximum number of threads which can be used by the web server\"\n        default 5\n      end\n    end\n\n    group :worker do\n      integer :default_health_server_port do\n        description \"The default port for the worker health server to listen on\"\n        default 9090\n      end\n\n      string :default_health_server_bind_address do\n        description \"The default bind address for the worker health server to listen on\"\n        default \"127.0.0.1\"\n      end\n\n      integer :threads do\n        description \"The number of threads to execute within each worker\"\n        default 2\n      end\n    end\n\n    group :main_db do\n      string :host do\n        description \"Hostname for the main MariaDB server\"\n        default \"localhost\"\n      end\n\n      integer :port do\n        description \"The MariaDB port to connect to\"\n        default 3306\n      end\n\n      string :username do\n        description \"The MariaDB username\"\n        default \"postal\"\n      end\n\n      string :password do\n        description \"The MariaDB password\"\n      end\n\n      string :database do\n        description \"The MariaDB database name\"\n        default \"postal\"\n      end\n\n      integer :pool_size do\n        description \"The maximum size of the MariaDB connection pool\"\n        default 5\n      end\n\n      string :encoding do\n        description \"The encoding to use when connecting to the MariaDB database\"\n        default \"utf8mb4\"\n      end\n    end\n\n    group :message_db do\n      string :host do\n        description \"Hostname for the MariaDB server which stores the mail server databases\"\n        default \"localhost\"\n      end\n\n      integer :port do\n        description \"The MariaDB port to connect to\"\n        default 3306\n      end\n\n      string :username do\n        description \"The MariaDB username\"\n        default \"postal\"\n      end\n\n      string :password do\n        description \"The MariaDB password\"\n      end\n\n      string :encoding do\n        description \"The encoding to use when connecting to the MariaDB database\"\n        default \"utf8mb4\"\n      end\n\n      string :database_name_prefix do\n        description \"The MariaDB prefix to add to database names\"\n        default \"postal\"\n      end\n    end\n\n    group :logging do\n      boolean :rails_log_enabled do\n        description \"Enable the default Rails logger\"\n        default false\n      end\n\n      string :sentry_dsn do\n        description \"A DSN which should be used to report exceptions to Sentry\"\n      end\n\n      boolean :enabled do\n        description \"Enable the Postal logger to log to STDOUT\"\n        default true\n      end\n\n      boolean :highlighting_enabled do\n        description \"Enable highlighting of log lines\"\n        default false\n      end\n    end\n\n    group :gelf do\n      string :host do\n        description \"GELF-capable host to send logs to\"\n      end\n\n      integer :port do\n        description \"GELF port to send logs to\"\n        default 12_201\n      end\n\n      string :facility do\n        description \"The facility name to add to all log entries sent to GELF\"\n        default \"postal\"\n      end\n    end\n\n    group :smtp_server do\n      integer :default_port do\n        description \"The default port the SMTP server should listen on unless overriden by the PORT environment variable\"\n        default 25\n      end\n\n      string :default_bind_address do\n        description \"The default bind address the SMTP server should listen on unless overriden by the BIND_ADDRESS environment variable\"\n        default \"::\"\n      end\n\n      integer :default_health_server_port do\n        description \"The default port for the SMTP server health server to listen on\"\n        default 9091\n      end\n\n      string :default_health_server_bind_address do\n        description \"The default bind address for the SMTP server health server to listen on\"\n        default \"127.0.0.1\"\n      end\n\n      boolean :tls_enabled do\n        description \"Enable TLS for the SMTP server (requires certificate)\"\n        default false\n      end\n\n      string :tls_certificate_path do\n        description \"The path to the SMTP server's TLS certificate\"\n        default \"$config-file-root/smtp.cert\"\n        transform { |v| Postal.substitute_config_file_root(v) }\n      end\n\n      string :tls_private_key_path do\n        description \"The path to the SMTP server's TLS private key\"\n        default \"$config-file-root/smtp.key\"\n        transform { |v| Postal.substitute_config_file_root(v) }\n      end\n\n      string :tls_ciphers do\n        description \"Override ciphers to use for SSL\"\n      end\n\n      string :ssl_version do\n        description \"The SSL versions which are supported\"\n        default \"SSLv23\"\n      end\n\n      boolean :proxy_protocol do\n        description \"Enable proxy protocol for use behind some load balancers (supports proxy protocol v1 only)\"\n        default false\n      end\n\n      boolean :log_connections do\n        description \"Enable connection logging\"\n        default false\n      end\n\n      integer :max_message_size do\n        description \"The maximum message size to accept from the SMTP server (in MB)\"\n        default 14\n      end\n\n      string :log_ip_address_exclusion_matcher do\n        description \"A regular expression to use to exclude connections from logging\"\n      end\n    end\n\n    group :dns do\n      string :mx_records do\n        description \"The names of the default MX records\"\n        array\n        default [\"mx1.postal.example.com\", \"mx2.postal.example.com\"]\n      end\n\n      string :spf_include do\n        description \"The location of the SPF record\"\n        default \"spf.postal.example.com\"\n      end\n\n      string :return_path_domain do\n        description \"The return path hostname\"\n        default \"rp.postal.example.com\"\n      end\n\n      string :route_domain do\n        description \"The domain to use for hosting route-specific addresses\"\n        default \"routes.postal.example.com\"\n      end\n\n      string :track_domain do\n        description \"The CNAME which tracking domains should be pointed to\"\n        default \"track.postal.example.com\"\n      end\n\n      string :helo_hostname do\n        description \"The hostname to use in HELO/EHLO when connecting to external SMTP servers\"\n      end\n\n      string :dkim_identifier do\n        description \"The identifier to use for DKIM keys in DNS records\"\n        default \"postal\"\n      end\n\n      string :domain_verify_prefix do\n        description \"The prefix to add before TXT record verification string\"\n        default \"postal-verification\"\n      end\n\n      string :custom_return_path_prefix do\n        description \"The domain to use on external domains which points to the Postal return path domain\"\n        default \"psrp\"\n      end\n\n      integer :timeout do\n        description \"The timeout to wait for DNS resolution\"\n        default 5\n      end\n\n      string :resolv_conf_path do\n        description \"The path to the resolv.conf file containing addresses for local nameservers\"\n        default \"/etc/resolv.conf\"\n      end\n    end\n\n    group :smtp do\n      string :host do\n        description \"The hostname to send application-level e-mails to\"\n        default \"127.0.0.1\"\n      end\n\n      integer :port do\n        description \"The port number to send application-level e-mails to\"\n        default 25\n      end\n\n      string :username do\n        description \"The username to use when authentication to the SMTP server\"\n      end\n\n      string :password do\n        description \"The password to use when authentication to the SMTP server\"\n      end\n\n      string :authentication_type do\n        description \"The type of authentication to use\"\n        default \"login\"\n      end\n\n      boolean :enable_starttls do\n        description \"Use STARTTLS when connecting to the SMTP server and fail if unsupported\"\n        default false\n      end\n\n      boolean :enable_starttls_auto do\n        description \"Detects if STARTTLS is enabled in the SMTP server and starts to use it\"\n        default true\n      end\n\n      string :openssl_verify_mode do\n        description \"When using TLS, you can set how OpenSSL checks the certificate. Use 'none' for no certificate checking\"\n        default \"peer\"\n      end\n\n      string :from_name do\n        description \"The name to use as the from name outgoing emails from Postal\"\n        default \"Postal\"\n      end\n\n      string :from_address do\n        description \"The e-mail to use as the from address outgoing emails from Postal\"\n        default \"postal@example.com\"\n      end\n    end\n\n    group :rails do\n      string :environment do\n        description \"The Rails environment to run the application in\"\n        default \"production\"\n      end\n\n      string :secret_key do\n        description \"The secret key used to sign and encrypt cookies and session data in the application\"\n      end\n    end\n\n    group :rspamd do\n      boolean :enabled do\n        description \"Enable rspamd for message inspection\"\n        default false\n      end\n\n      string :host do\n        description \"The hostname of the rspamd server\"\n        default \"127.0.0.1\"\n      end\n\n      integer :port do\n        description \"The port of the rspamd server\"\n        default 11_334\n      end\n\n      boolean :ssl do\n        description \"Enable SSL for the rspamd connection\"\n        default false\n      end\n\n      string :password do\n        description \"The password for the rspamd server\"\n      end\n\n      string :flags do\n        description \"Any flags for the rspamd server\"\n      end\n    end\n\n    group :spamd do\n      boolean :enabled do\n        description \"Enable SpamAssassin for message inspection\"\n        default false\n      end\n\n      string :host do\n        description \"The hostname for the SpamAssassin server\"\n        default \"127.0.0.1\"\n      end\n\n      integer :port do\n        description \"The port of the SpamAssassin server\"\n        default 783\n      end\n    end\n\n    group :clamav do\n      boolean :enabled do\n        description \"Enable ClamAV for message inspection\"\n        default false\n      end\n\n      string :host do\n        description \"The host of the ClamAV server\"\n        default \"127.0.0.1\"\n      end\n\n      integer :port do\n        description \"The port of the ClamAV server\"\n        default 2000\n      end\n    end\n\n    group :smtp_client do\n      integer :open_timeout do\n        description \"The open timeout for outgoing SMTP connections\"\n        default 30\n      end\n\n      integer :read_timeout do\n        description \"The read timeout for outgoing SMTP connections\"\n        default 30\n      end\n    end\n\n    group :migration_waiter do\n      boolean :enabled do\n        description \"Wait for all migrations to run before starting a process\"\n        default false\n      end\n\n      integer :attempts do\n        description \"The number of attempts to try waiting for migrations to complete before start\"\n        default 120\n      end\n\n      integer :sleep_time do\n        description \"The number of seconds to wait between each migration check\"\n        default 2\n      end\n    end\n\n    group :oidc do\n      boolean :enabled do\n        description \"Enable OIDC authentication\"\n        default false\n      end\n\n      boolean :local_authentication_enabled do\n        description \"When enabled, users with passwords will still be able to login locally. If disable, only OpenID Connect will be available.\"\n        default true\n      end\n\n      string :name do\n        description \"The name of the OIDC provider as shown in the UI\"\n        default \"OIDC Provider\"\n      end\n\n      string :issuer do\n        description \"The OIDC issuer URL\"\n      end\n\n      string :identifier do\n        description \"The client ID for OIDC\"\n      end\n\n      string :secret do\n        description \"The client secret for OIDC\"\n      end\n\n      string :scopes do\n        description \"Scopes to request from the OIDC server.\"\n        array\n        default [\"openid\", \"email\"]\n      end\n\n      string :uid_field do\n        description \"The field to use to determine the user's UID\"\n        default \"sub\"\n      end\n\n      string :email_address_field do\n        description \"The field to use to determine the user's email address\"\n        default \"email\"\n      end\n\n      string :name_field do\n        description \"The field to use to determine the user's name\"\n        default \"name\"\n      end\n\n      boolean :discovery do\n        description \"Enable discovery to determine endpoints from .well-known/openid-configuration from the Issuer\"\n        default true\n      end\n\n      string :authorization_endpoint do\n        description \"The authorize endpoint on the authorization server (only used when discovery is false)\"\n      end\n\n      string :token_endpoint do\n        description \"The token endpoint on the authorization server (only used when discovery is false)\"\n      end\n\n      string :userinfo_endpoint do\n        description \"The user info endpoint on the authorization server (only used when discovery is false)\"\n      end\n\n      string :jwks_uri do\n        description \"The JWKS endpoint on the authorization server (only used when discovery is false)\"\n      end\n    end\n  end\n\n  class << self\n\n    def substitute_config_file_root(string)\n      return if string.nil?\n\n      string.gsub(/\\$config-file-root/i, File.dirname(Postal.config_file_path))\n    end\n\n  end\n\nend\n"
  },
  {
    "path": "lib/postal/error.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n\n  class Error < StandardError\n  end\n\n  module Errors\n    class AuthenticationError < Error\n\n      attr_reader :error\n\n      def initialize(error)\n        super()\n        @error = error\n      end\n\n      def to_s\n        \"Authentication Failed: #{@error}\"\n      end\n\n    end\n  end\n\nend\n"
  },
  {
    "path": "lib/postal/helm_config_exporter.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"konfig/exporters/abstract\"\n\nmodule Postal\n  class HelmConfigExporter < Konfig::Exporters::Abstract\n\n    def export\n      contents = []\n\n      path = []\n\n      @schema.groups.each do |group_name, group|\n        path << group_name\n        group.attributes.each do |name, _|\n          env_var = Konfig::Sources::Environment.path_to_env_var(path + [name])\n          contents << <<~VAR.strip\n            {{ include \"app.envVar\" (dict \"name\" \"#{env_var}\" \"spec\" .Values.postal.#{path.join('.')}.#{name} \"root\" . ) }}\n          VAR\n        end\n        path.pop\n      end\n\n      contents.join(\"\\n\")\n    end\n\n  end\nend\n"
  },
  {
    "path": "lib/postal/helpers.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module Helpers\n\n    def self.strip_name_from_address(address)\n      return nil if address.nil?\n\n      address.gsub(/.*</, \"\").gsub(/>.*/, \"\").gsub(/\\(.+?\\)/, \"\").strip\n    end\n\n  end\nend\n"
  },
  {
    "path": "lib/postal/http.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"net/https\"\nrequire \"uri\"\n\nmodule Postal\n  module HTTP\n\n    def self.get(url, options = {})\n      request(Net::HTTP::Get, url, options)\n    end\n\n    def self.post(url, options = {})\n      request(Net::HTTP::Post, url, options)\n    end\n\n    def self.request(method, url, options = {})\n      options[:headers] ||= {}\n      uri = URI.parse(url)\n      request = method.new((uri.path.empty? ? \"/\" : uri.path) + (uri.query ? \"?\" + uri.query : \"\"))\n      options[:headers].each { |k, v| request.add_field k, v }\n\n      if options[:username] || uri.user\n        request.basic_auth(options[:username] || uri.user, options[:password] || uri.password)\n      end\n\n      if options[:params].is_a?(Hash)\n        # If params has been provided, sent it them as form encoded values\n        request.set_form_data(options[:params])\n\n      elsif options[:json].is_a?(String)\n        # If we have a JSON string, set the content type and body to be the JSON\n        # data\n        request.add_field \"Content-Type\", \"application/json\"\n        request.body = options[:json]\n\n      elsif options[:text_body]\n        # Add a plain text body if we have one\n        request.body = options[:text_body]\n      end\n\n      if options[:sign]\n        request.add_field \"X-Postal-Signature-KID\", Postal.signer.jwk.kid\n        request.add_field \"X-Postal-Signature\", Postal.signer.sha1_sign64(request.body.to_s)\n        request.add_field \"X-Postal-Signature-256\", Postal.signer.sign64(request.body.to_s)\n      end\n\n      request[\"User-Agent\"] = options[:user_agent] || \"Postal/#{Postal.version}\"\n\n      connection = Net::HTTP.new(uri.host, uri.port)\n\n      if uri.scheme == \"https\"\n        connection.use_ssl = true\n        connection.verify_mode = OpenSSL::SSL::VERIFY_PEER\n        ssl = true\n      else\n        ssl = false\n      end\n\n      begin\n        timeout = options[:timeout] || 60\n        Timeout.timeout(timeout) do\n          result = connection.request(request)\n          {\n            code: result.code.to_i,\n            body: result.body,\n            headers: result.to_hash,\n            secure: ssl\n          }\n        end\n      rescue OpenSSL::SSL::SSLError\n        {\n          code: -3,\n          body: \"Invalid SSL certificate\",\n          headers: {},\n          secure: ssl\n        }\n      rescue SocketError, Errno::ECONNRESET, EOFError, Errno::EINVAL, Errno::ENETUNREACH, Errno::EHOSTUNREACH, Errno::ECONNREFUSED => e\n        {\n          code: -2,\n          body: e.message,\n          headers: {},\n          secure: ssl\n        }\n      rescue Timeout::Error\n        {\n          code: -1,\n          body: \"Timed out after #{timeout}s\",\n          headers: {},\n          secure: ssl\n        }\n      end\n    end\n\n  end\nend\n"
  },
  {
    "path": "lib/postal/legacy_config_source.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"konfig/sources/abstract\"\nrequire \"konfig/error\"\n\nmodule Postal\n  class LegacyConfigSource < Konfig::Sources::Abstract\n\n    # This maps all the new configuration values to where they\n    # exist in the old YAML file. The source will load any YAML\n    # file that has been provided to this source in order. A\n    # warning will be generated to the console for configuration\n    # loaded from this format.\n    MAPPING = {\n      \"postal.web_hostname\" => -> (c) { c.dig(\"web\", \"host\") },\n      \"postal.web_protocol\" => -> (c) { c.dig(\"web\", \"protocol\") },\n      \"postal.smtp_hostname\" => -> (c) { c.dig(\"dns\", \"smtp_server_hostname\") },\n      \"postal.use_ip_pools\" => -> (c) { c.dig(\"general\", \"use_ip_pools\") },\n      \"logging.sentry_dsn\" => -> (c) { c.dig(\"general\", \"exception_url\") },\n      \"postal.default_maximum_delivery_attempts\" => -> (c) { c.dig(\"general\", \"maximum_delivery_attempts\") },\n      \"postal.default_maximum_hold_expiry_days\" => -> (c) { c.dig(\"general\", \"maximum_hold_expiry_days\") },\n      \"postal.default_suppression_list_automatic_removal_days\" => -> (c) { c.dig(\"general\", \"suppression_list_removal_delay\") },\n      \"postal.use_local_ns_for_domain_verification\" => -> (c) { c.dig(\"general\", \"use_local_ns_for_domains\") },\n      \"postal.default_spam_threshold\" => -> (c) { c.dig(\"general\", \"default_spam_threshold\") },\n      \"postal.default_spam_failure_threshold\" => -> (c) { c.dig(\"general\", \"default_spam_failure_threshold\") },\n      \"postal.use_resent_sender_header\" => -> (c) { c.dig(\"general\", \"use_resent_sender_header\") },\n      # SMTP relays must be converted to the new URI style format and they'll\n      # then be transformed back to a hash by the schema transform.\n      \"postal.smtp_relays\" => -> (c) { c[\"smtp_relays\"]&.map { |r| \"smtp://#{r['hostname']}:#{r['port']}?ssl_mode=#{r['ssl_mode']}\" } },\n\n      \"web_server.default_bind_address\" => -> (c) { c.dig(\"web_server\", \"bind_address\") },\n      \"web_server.default_port\" => -> (c) { c.dig(\"web_server\", \"port\") },\n      \"web_server.max_threads\" => -> (c) { c.dig(\"web_server\", \"max_threads\") },\n\n      \"main_db.host\" => -> (c) { c.dig(\"main_db\", \"host\") },\n      \"main_db.port\" => -> (c) { c.dig(\"main_db\", \"port\") },\n      \"main_db.username\" => -> (c) { c.dig(\"main_db\", \"username\") },\n      \"main_db.password\" => -> (c) { c.dig(\"main_db\", \"password\") },\n      \"main_db.database\" => -> (c) { c.dig(\"main_db\", \"database\") },\n      \"main_db.pool_size\" => -> (c) { c.dig(\"main_db\", \"pool_size\") },\n      \"main_db.encoding\" => -> (c) { c.dig(\"main_db\", \"encoding\") },\n\n      \"message_db.host\" => -> (c) { c.dig(\"message_db\", \"host\") },\n      \"message_db.port\" => -> (c) { c.dig(\"message_db\", \"port\") },\n      \"message_db.username\" => -> (c) { c.dig(\"message_db\", \"username\") },\n      \"message_db.password\" => -> (c) { c.dig(\"message_db\", \"password\") },\n      \"message_db.database_name_prefix\" => -> (c) { c.dig(\"message_db\", \"prefix\") },\n\n      \"logging.rails_log_enabled\" => -> (c) { c.dig(\"logging\", \"rails_log\") },\n\n      \"gelf.host\" => -> (c) { c.dig(\"logging\", \"graylog\", \"host\") },\n      \"gelf.port\" => -> (c) { c.dig(\"logging\", \"graylog\", \"port\") },\n      \"gelf.facility\" => -> (c) { c.dig(\"logging\", \"graylog\", \"facility\") },\n\n      \"smtp_server.default_port\" => -> (c) { c.dig(\"smtp_server\", \"port\") },\n      \"smtp_server.default_bind_address\" => -> (c) { c.dig(\"smtp_server\", \"bind_address\") || \"::\" },\n      \"smtp_server.tls_enabled\" => -> (c) { c.dig(\"smtp_server\", \"tls_enabled\") },\n      \"smtp_server.tls_certificate_path\" => -> (c) { c.dig(\"smtp_server\", \"tls_certificate_path\") },\n      \"smtp_server.tls_private_key_path\" => -> (c) { c.dig(\"smtp_server\", \"tls_private_key_path\") },\n      \"smtp_server.tls_ciphers\" => -> (c) { c.dig(\"smtp_server\", \"tls_ciphers\") },\n      \"smtp_server.ssl_version\" => -> (c) { c.dig(\"smtp_server\", \"ssl_version\") },\n      \"smtp_server.proxy_protocol\" => -> (c) { c.dig(\"smtp_server\", \"proxy_protocol\") },\n      \"smtp_server.log_connections\" => -> (c) { c.dig(\"smtp_server\", \"log_connect\") },\n      \"smtp_server.max_message_size\" => -> (c) { c.dig(\"smtp_server\", \"max_message_size\") },\n\n      \"dns.mx_records\" => -> (c) { c.dig(\"dns\", \"mx_records\") },\n      \"dns.spf_include\" => -> (c) { c.dig(\"dns\", \"spf_include\") },\n      \"dns.return_path_domain\" => -> (c) { c.dig(\"dns\", \"return_path\") },\n      \"dns.route_domain\" => -> (c) { c.dig(\"dns\", \"route_domain\") },\n      \"dns.track_domain\" => -> (c) { c.dig(\"dns\", \"track_domain\") },\n      \"dns.helo_hostname\" => -> (c) { c.dig(\"dns\", \"helo_hostname\") },\n      \"dns.dkim_identifier\" => -> (c) { c.dig(\"dns\", \"dkim_identifier\") },\n      \"dns.domain_verify_prefix\" => -> (c) { c.dig(\"dns\", \"domain_verify_prefix\") },\n      \"dns.custom_return_path_prefix\" => -> (c) { c.dig(\"dns\", \"custom_return_path_prefix\") },\n\n      \"smtp.host\" => -> (c) { c.dig(\"smtp\", \"host\") },\n      \"smtp.port\" => -> (c) { c.dig(\"smtp\", \"port\") },\n      \"smtp.username\" => -> (c) { c.dig(\"smtp\", \"username\") },\n      \"smtp.password\" => -> (c) { c.dig(\"smtp\", \"password\") },\n      \"smtp.from_name\" => -> (c) { c.dig(\"smtp\", \"from_name\") },\n      \"smtp.from_address\" => -> (c) { c.dig(\"smtp\", \"from_address\") },\n\n      \"rails.environment\" => -> (c) { c.dig(\"rails\", \"environment\") },\n      \"rails.secret_key\" => -> (c) { c.dig(\"rails\", \"secret_key\") },\n\n      \"rspamd.enabled\" => -> (c) { c.dig(\"rspamd\", \"enabled\") },\n      \"rspamd.host\" => -> (c) { c.dig(\"rspamd\", \"host\") },\n      \"rspamd.port\" => -> (c) { c.dig(\"rspamd\", \"port\") },\n      \"rspamd.ssl\" => -> (c) { c.dig(\"rspamd\", \"ssl\") },\n      \"rspamd.password\" => -> (c) { c.dig(\"rspamd\", \"password\") },\n      \"rspamd.flags\" => -> (c) { c.dig(\"rspamd\", \"flags\") },\n\n      \"spamd.enabled\" => -> (c) { c.dig(\"spamd\", \"enabled\") },\n      \"spamd.host\" => -> (c) { c.dig(\"spamd\", \"host\") },\n      \"spamd.port\" => -> (c) { c.dig(\"spamd\", \"port\") },\n\n      \"clamav.enabled\" => -> (c) { c.dig(\"clamav\", \"enabled\") },\n      \"clamav.host\" => -> (c) { c.dig(\"clamav\", \"host\") },\n      \"clamav.port\" => -> (c) { c.dig(\"clamav\", \"port\") },\n\n      \"smtp_client.open_timeout\" => -> (c) { c.dig(\"smtp_client\", \"open_timeout\") },\n      \"smtp_client.read_timeout\" => -> (c) { c.dig(\"smtp_client\", \"read_timeout\") }\n\n    }.freeze\n\n    def initialize(config)\n      super()\n      @config = config\n    end\n\n    def get(path, attribute: nil)\n      path_string = path.join(\".\")\n      raise Konfig::ValueNotPresentError unless MAPPING.key?(path_string)\n\n      legacy_value = MAPPING[path_string].call(@config)\n      raise Konfig::ValueNotPresentError if legacy_value.nil?\n\n      legacy_value\n    end\n\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/click.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    class Click\n\n      def initialize(attributes, link)\n        @url = link[\"url\"]\n        @ip_address = attributes[\"ip_address\"]\n        @user_agent = attributes[\"user_agent\"]\n        @timestamp = Time.zone.at(attributes[\"timestamp\"])\n      end\n\n      attr_reader :ip_address\n      attr_reader :user_agent\n      attr_reader :timestamp\n      attr_reader :url\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/connection_pool.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    class ConnectionPool\n\n      attr_reader :connections\n\n      def initialize\n        @connections = []\n        @lock = Mutex.new\n      end\n\n      def use\n        retried = false\n        do_not_checkin = false\n        begin\n          connection = checkout\n\n          yield connection\n        rescue Mysql2::Error => e\n          if e.message =~ /(lost connection|gone away|not connected)/i\n            # If the connection has failed for a connectivity reason\n            # we won't add it back in to the pool so that it'll reconnect\n            # next time.\n            do_not_checkin = true\n\n            # If we haven't retried yet, we'll retry the block once more.\n            if retried == false\n              retried = true\n              retry\n            end\n          end\n\n          raise\n        ensure\n          checkin(connection) unless do_not_checkin\n        end\n      end\n\n      private\n\n      def checkout\n        @lock.synchronize do\n          return @connections.pop unless @connections.empty?\n        end\n\n        add_new_connection\n        checkout\n      end\n\n      def checkin(connection)\n        @lock.synchronize do\n          @connections << connection\n        end\n      end\n\n      def add_new_connection\n        @lock.synchronize do\n          @connections << establish_connection\n        end\n      end\n\n      def establish_connection\n        Mysql2::Client.new(\n          host: Postal::Config.message_db.host,\n          username: Postal::Config.message_db.username,\n          password: Postal::Config.message_db.password,\n          port: Postal::Config.message_db.port,\n          encoding: Postal::Config.message_db.encoding\n        )\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/database.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    class Database\n\n      class << self\n\n        def connection_pool\n          @connection_pool ||= ConnectionPool.new\n        end\n\n      end\n\n      def initialize(organization_id, server_id, database_name: nil)\n        @organization_id = organization_id\n        @server_id = server_id\n        @database_name = database_name\n      end\n\n      attr_reader :organization_id\n      attr_reader :server_id\n\n      #\n      # Return the server\n      #\n      def server\n        @server ||= Server.find_by_id(@server_id)\n      end\n\n      #\n      # Return the current schema version\n      #\n      def schema_version\n        @schema_version ||= begin\n          last_migration = select(:migrations, order: :version, direction: \"DESC\", limit: 1).first\n          last_migration ? last_migration[\"version\"] : 0\n        rescue Mysql2::Error => e\n          e.message =~ /doesn't exist/ ? 0 : raise\n        end\n      end\n\n      #\n      # Return a single message. Accepts an ID or an array of conditions\n      #\n      def message(*args)\n        Message.find_one(self, *args)\n      end\n\n      #\n      # Return an array or count of messages.\n      #\n      def messages(*args)\n        Message.find(self, *args)\n      end\n\n      def messages_with_pagination(*args)\n        Message.find_with_pagination(self, *args)\n      end\n\n      #\n      # Create a new message with the given attributes. This won't be saved to the database\n      # until it has been 'save'd.\n      #\n      def new_message(attributes = {})\n        Message.new(self, attributes)\n      end\n\n      #\n      # Return the total size of all stored messages\n      #\n      def total_size\n        query(\"SELECT SUM(size) AS size FROM `#{database_name}`.`raw_message_sizes`\").first[\"size\"] || 0\n      end\n\n      #\n      # Return the live stats instance\n      #\n      def live_stats\n        @live_stats ||= LiveStats.new(self)\n      end\n\n      #\n      # Return the statistics instance\n      #\n      def statistics\n        @statistics ||= Statistics.new(self)\n      end\n\n      #\n      # Return the provisioner instance\n      #\n      def provisioner\n        @provisioner ||= Provisioner.new(self)\n      end\n\n      #\n      # Return the provisioner instance\n      #\n      def suppression_list\n        @suppression_list ||= SuppressionList.new(self)\n      end\n\n      #\n      # Return the provisioner instance\n      #\n      def webhooks\n        @webhooks ||= Webhooks.new(self)\n      end\n\n      #\n      # Return the name for a raw message table for a given date\n      #\n      def raw_table_name_for_date(date)\n        date.strftime(\"raw-%Y-%m-%d\")\n      end\n\n      #\n      # Insert a new raw message into a table (creating it if needed)\n      #\n      def insert_raw_message(data, date = Time.now.utc.to_date)\n        table_name = raw_table_name_for_date(date)\n        begin\n          headers, body = data.split(/\\r?\\n\\r?\\n/, 2)\n          headers_id = insert(table_name, data: headers)\n          body_id = insert(table_name, data: body)\n        rescue Mysql2::Error => e\n          raise unless e.message =~ /doesn't exist/\n\n          provisioner.create_raw_table(table_name)\n          retry\n        end\n        [table_name, headers_id, body_id]\n      end\n\n      #\n      # Selects entries from the database. Accepts a number of options which can be used\n      # to manipulate the results.\n      #\n      #   :where     => A hash containing the query\n      #   :order     => The name of a field to order by\n      #   :direction => The order that should be applied to ordering (ASC or DESC)\n      #   :fields    => An array of fields to select\n      #   :limit     => Limit the number of results\n      #   :page      => Which page number to return\n      #   :per_page  => The number of items per page (defaults to 30)\n      #   :count     => Return a count of the results instead of the actual data\n      #\n      def select(table, options = {})\n        sql_query = String.new(\"SELECT\")\n        if options[:count]\n          sql_query << \" COUNT(id) AS count\"\n        elsif options[:fields]\n          sql_query << (\" \" + options[:fields].map { |f| \"`#{f}`\" }.join(\", \"))\n        else\n          sql_query << \" *\"\n        end\n        sql_query << \" FROM `#{database_name}`.`#{table}`\"\n        if options[:where].present?\n          sql_query << (\" \" + build_where_string(options[:where], \" AND \"))\n        end\n        if options[:order]\n          direction = (options[:direction] || \"ASC\").upcase\n          raise Postal::Error, \"Invalid direction #{options[:direction]}\" unless %w[ASC DESC].include?(direction)\n\n          sql_query << \" ORDER BY `#{options[:order]}` #{direction}\"\n        end\n\n        if options[:limit]\n          sql_query << \" LIMIT #{options[:limit]}\"\n        end\n\n        if options[:offset]\n          sql_query << \" OFFSET #{options[:offset]}\"\n        end\n\n        result = query(sql_query)\n        if options[:count]\n          result.first[\"count\"]\n        else\n          result.to_a\n        end\n      end\n\n      #\n      # A paginated version of select\n      #\n      def select_with_pagination(table, page, options = {})\n        page = page.to_i\n        page = 1 if page <= 0\n\n        per_page = options.delete(:per_page) || 30\n        offset = (page - 1) * per_page\n\n        result = {}\n        result[:total] = select(table, options.merge(count: true))\n        result[:records] = select(table, options.merge(limit: per_page, offset: offset))\n        result[:per_page] = per_page\n        result[:total_pages], remainder = result[:total].divmod(per_page)\n        result[:total_pages] += 1 if remainder.positive?\n        result[:page] = page\n        result\n      end\n\n      #\n      # Updates a record in the database. Accepts a table name, the attributes to update\n      # plus some options which are shown below:\n      #\n      #   :where     => The condition to apply to the query\n      #\n      # Will return the total number of affected rows.\n      #\n      def update(table, attributes, options = {})\n        sql_query = \"UPDATE `#{database_name}`.`#{table}` SET\"\n        sql_query << \" #{hash_to_sql(attributes)}\"\n        if options[:where]\n          sql_query << (\" \" + build_where_string(options[:where]))\n        end\n        with_mysql do |mysql|\n          query_on_connection(mysql, sql_query)\n          mysql.affected_rows\n        end\n      end\n\n      #\n      # Insert a record into a given table. A hash of attributes is also provided.\n      # Will return the ID of the new item.\n      #\n      def insert(table, attributes)\n        sql_query = \"INSERT INTO `#{database_name}`.`#{table}`\"\n        sql_query << (\" (\" + attributes.keys.map { |k| \"`#{k}`\" }.join(\", \") + \")\")\n        sql_query << (\" VALUES (\" + attributes.values.map { |v| escape(v) }.join(\", \") + \")\")\n        with_mysql do |mysql|\n          query_on_connection(mysql, sql_query)\n          mysql.last_id\n        end\n      end\n\n      #\n      # Insert multiple rows at the same time in the same query\n      #\n      def insert_multi(table, keys, values)\n        if values.empty?\n          nil\n        else\n          sql_query = \"INSERT INTO `#{database_name}`.`#{table}`\"\n          sql_query << (\" (\" + keys.map { |k| \"`#{k}`\" }.join(\", \") + \")\")\n          sql_query << \" VALUES \"\n          sql_query << values.map { |v| \"(\" + v.map { |r| escape(r) }.join(\", \") + \")\" }.join(\", \")\n          query(sql_query)\n        end\n      end\n\n      #\n      # Deletes a in the database. Accepts a table name, and some options which\n      # are shown below:\n      #\n      #   :where     => The condition to apply to the query\n      #\n      # Will return the total number of affected rows.\n      #\n      def delete(table, options = {})\n        sql_query = \"DELETE FROM `#{database_name}`.`#{table}`\"\n        sql_query << (\" \" + build_where_string(options[:where], \" AND \"))\n        with_mysql do |mysql|\n          query_on_connection(mysql, sql_query)\n          mysql.affected_rows\n        end\n      end\n\n      #\n      # Return the correct database name\n      #\n      def database_name\n        @database_name ||= \"#{Postal::Config.message_db.database_name_prefix}-server-#{@server_id}\"\n      end\n\n      #\n      # Run a query, log it and return the result\n      #\n      class ResultForExplainPrinter\n\n        attr_reader :columns\n        attr_reader :rows\n\n        def initialize(result)\n          if result.first\n            @columns = result.first.keys\n            @rows = result.map { |row| row.map(&:last) }\n          else\n            @columns = []\n            @rows = []\n          end\n        end\n\n      end\n\n      def stringify_keys(hash)\n        hash.transform_keys(&:to_s)\n      end\n\n      def escape(value)\n        with_mysql do |mysql|\n          if value == true\n            \"1\"\n          elsif value == false\n            \"0\"\n          elsif value.nil? || value.to_s.empty?\n            \"NULL\"\n          else\n            \"'\" + mysql.escape(value.to_s) + \"'\"\n          end\n        end\n      end\n\n      def query(query)\n        with_mysql do |mysql|\n          query_on_connection(mysql, query)\n        end\n      end\n\n      private\n\n      def query_on_connection(connection, query)\n        start_time = Time.now.to_f\n        result = connection.query(query, cast_booleans: true)\n        time = Time.now.to_f - start_time\n        logger.debug \"  \\e[4;34mMessageDB Query (#{time.round(2)}s) \\e[0m  \\e[33m#{query}\\e[0m\"\n        if time > 0.05 && query =~ /\\A(SELECT|UPDATE|DELETE) /\n          id = SecureRandom.alphanumeric(8)\n          explain_result = ResultForExplainPrinter.new(connection.query(\"EXPLAIN #{query}\"))\n          logger.info \"  [#{id}] EXPLAIN #{query}\"\n          ActiveRecord::ConnectionAdapters::MySQL::ExplainPrettyPrinter.new.pp(explain_result, time).split(\"\\n\").each do |line|\n            logger.info \"  [#{id}] \" + line\n          end\n        end\n        result\n      end\n\n      def logger\n        defined?(Rails) ? Rails.logger : Logger.new($stdout)\n      end\n\n      def with_mysql(&block)\n        self.class.connection_pool.use(&block)\n      end\n\n      def build_where_string(attributes, joiner = \", \")\n        \"WHERE #{hash_to_sql(attributes, joiner)}\"\n      end\n\n      def hash_to_sql(hash, joiner = \", \")\n        hash.map do |key, value|\n          if value.is_a?(Array) && value.all? { |v| v.is_a?(Integer) }\n            \"`#{key}` IN (#{value.join(', ')})\"\n          elsif value.is_a?(Array)\n            escaped_values = value.map { |v| escape(v) }.join(\", \")\n            \"`#{key}` IN (#{escaped_values})\"\n          elsif value.is_a?(Hash)\n            sql = []\n            value.each do |operator, inner_value|\n              case operator\n              when :less_than\n                sql << \"`#{key}` < #{escape(inner_value)}\"\n              when :greater_than\n                sql << \"`#{key}` > #{escape(inner_value)}\"\n              when :less_than_or_equal_to\n                sql << \"`#{key}` <= #{escape(inner_value)}\"\n              when :greater_than_or_equal_to\n                sql << \"`#{key}` >= #{escape(inner_value)}\"\n              end\n            end\n            sql.empty? ? \"1=1\" : sql.join(joiner)\n          else\n            \"`#{key}` = #{escape(value)}\"\n          end\n        end.join(joiner)\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/delivery.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    class Delivery\n\n      def self.create(message, attributes = {})\n        attributes = message.database.stringify_keys(attributes)\n        attributes = attributes.merge(\"message_id\" => message.id, \"timestamp\" => Time.now.to_f)\n\n        # Ensure that output and details don't overflow their columns. We don't need\n        # these values to store more than 250 characters.\n        attributes[\"output\"] = attributes[\"output\"][0, 250] if attributes[\"output\"]\n        attributes[\"details\"] = attributes[\"details\"][0, 250] if attributes[\"details\"]\n\n        id = message.database.insert(\"deliveries\", attributes)\n\n        delivery = Delivery.new(message, attributes.merge(\"id\" => id))\n        delivery.update_statistics\n        delivery.send_webhooks\n        delivery\n      end\n\n      def initialize(message, attributes)\n        @message = message\n        @attributes = attributes.stringify_keys\n      end\n\n      def method_missing(name, value = nil, &block)\n        return unless @attributes.key?(name.to_s)\n\n        @attributes[name.to_s]\n      end\n\n      def respond_to_missing?(name, include_private = false)\n        @attributes.key?(name.to_s)\n      end\n\n      def timestamp\n        @timestamp ||= @attributes[\"timestamp\"] ? Time.zone.at(@attributes[\"timestamp\"]) : nil\n      end\n\n      def update_statistics\n        if status == \"Held\"\n          @message.database.statistics.increment_all(timestamp, \"held\")\n        end\n\n        return unless status == \"Bounced\" || status == \"HardFail\"\n\n        @message.database.statistics.increment_all(timestamp, \"bounces\")\n      end\n\n      def send_webhooks\n        return unless webhook_event\n\n        WebhookRequest.trigger(@message.database.server_id, webhook_event, webhook_hash)\n      end\n\n      def webhook_hash\n        {\n          message: @message.webhook_hash,\n          status: status,\n          details: details,\n          output: output.to_s.dup.force_encoding(\"UTF-8\").scrub.truncate(512),\n          sent_with_ssl: sent_with_ssl,\n          timestamp: @attributes[\"timestamp\"],\n          time: time\n        }\n      end\n\n      # rubocop:disable Style/HashLikeCase\n      def webhook_event\n        @webhook_event ||= case status\n                           when \"Sent\" then \"MessageSent\"\n                           when \"SoftFail\" then \"MessageDelayed\"\n                           when \"HardFail\" then \"MessageDeliveryFailed\"\n                           when \"Held\" then \"MessageHeld\"\n                           end\n      end\n      # rubocop:enable Style/HashLikeCase\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/live_stats.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    class LiveStats\n\n      def initialize(database)\n        @database = database\n      end\n\n      #\n      # Increment the live stats by one for the current minute\n      #\n      def increment(type)\n        time = Time.now.utc\n        type = @database.escape(type.to_s)\n        sql_query = \"INSERT INTO `#{@database.database_name}`.`live_stats` (type, minute, timestamp, count)\"\n        sql_query << \" VALUES (#{type}, #{time.min}, #{time.to_f}, 1)\"\n        sql_query << \" ON DUPLICATE KEY UPDATE count = if(timestamp < #{time.to_f - 1800}, 1, count + 1), timestamp = #{time.to_f}\"\n        @database.query(sql_query)\n      end\n\n      #\n      # Return the total number of messages for the last 60 minutes\n      #\n      def total(minutes, options = {})\n        if minutes > 60\n          raise Postal::Error, \"Live stats can only return data for the last 60 minutes.\"\n        end\n\n        options[:types] ||= [:incoming, :outgoing]\n        raise Postal::Error, \"You must provide at least one type to return\" if options[:types].empty?\n\n        time = minutes.minutes.ago.beginning_of_minute.utc.to_f\n        types = options[:types].map { |t| @database.escape(t.to_s) }.join(\", \")\n        result = @database.query(\"SELECT SUM(count) as count FROM `#{@database.database_name}`.`live_stats` WHERE `type` IN (#{types}) AND timestamp > #{time}\").first\n        result[\"count\"] || 0\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/load.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    class Load\n\n      def initialize(attributes)\n        @ip_address = attributes[\"ip_address\"]\n        @user_agent = attributes[\"user_agent\"]\n        @timestamp = Time.zone.at(attributes[\"timestamp\"])\n      end\n\n      attr_reader :ip_address\n      attr_reader :user_agent\n      attr_reader :timestamp\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/message.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    class Message\n\n      class NotFound < Postal::Error\n      end\n\n      def self.find_one(database, query)\n        query = { id: query.to_i } if query.is_a?(Integer)\n        raise NotFound, \"No message found matching provided query #{query}\" unless message = database.select(\"messages\", where: query, limit: 1).first\n\n        Message.new(database, message)\n      end\n\n      def self.find(database, options = {})\n        if messages = database.select(\"messages\", options)\n          if messages.is_a?(Array)\n            messages.map { |m| Message.new(database, m) }\n          else\n            messages\n          end\n        else\n          []\n        end\n      end\n\n      def self.find_with_pagination(database, page, options = {})\n        messages = database.select_with_pagination(\"messages\", page, options)\n        messages[:records] = messages[:records].map { |m| Message.new(database, m) }\n        messages\n      end\n\n      attr_reader :database\n\n      def initialize(database, attributes)\n        @database = database\n        @attributes = attributes\n      end\n\n      def reload\n        self.class.find_one(@database, @attributes[\"id\"])\n      end\n\n      #\n      # Return the server for this message\n      #\n      def server\n        @database.server\n      end\n\n      #\n      # Return the credential for this message\n      #\n      def credential\n        @credential ||= credential_id ? Credential.find_by_id(credential_id) : nil\n      end\n\n      #\n      # Return the route for this message\n      #\n      def route\n        @route ||= route_id ? Route.find_by_id(route_id) : nil\n      end\n\n      #\n      # Return the endpoint for this message\n      #\n      def endpoint\n        if endpoint_type && endpoint_id\n          @endpoint ||= endpoint_type.constantize.find_by_id(endpoint_id)\n        elsif route && route.mode == \"Endpoint\"\n          @endpoint ||= route.endpoint\n        end\n      end\n\n      #\n      # Return the credential for this message\n      #\n      def domain\n        @domain ||= domain_id ? Domain.find_by_id(domain_id) : nil\n      end\n\n      #\n      # Copy appropriate attributes from the raw message to the message itself\n      #\n      def copy_attributes_from_raw_message\n        return unless raw_message\n\n        self.subject = headers[\"subject\"]&.last.to_s[0, 200]\n        self.message_id = headers[\"message-id\"]&.last\n        return unless message_id\n\n        self.message_id = message_id.gsub(/.*</, \"\").gsub(/>.*/, \"\").strip\n      end\n\n      #\n      # Return the timestamp for this message\n      #\n      def timestamp\n        @timestamp ||= @attributes[\"timestamp\"] ? Time.zone.at(@attributes[\"timestamp\"]) : nil\n      end\n\n      #\n      # Return the time that the last delivery was attempted\n      #\n      def last_delivery_attempt\n        @last_delivery_attempt ||= @attributes[\"last_delivery_attempt\"] ? Time.zone.at(@attributes[\"last_delivery_attempt\"]) : nil\n      end\n\n      #\n      # Return the hold expiry for this message\n      #\n      def hold_expiry\n        @hold_expiry ||= @attributes[\"hold_expiry\"] ? Time.zone.at(@attributes[\"hold_expiry\"]) : nil\n      end\n\n      #\n      # Has this message been read?\n      #\n      def read?\n        !!(loaded || clicked)\n      end\n\n      #\n      # Add a delivery attempt for this message\n      #\n      def create_delivery(status, options = {})\n        delivery = Delivery.create(self, options.merge(status: status))\n        hold_expiry = status == \"Held\" ? Postal::Config.postal.default_maximum_hold_expiry_days.days.from_now.to_f : nil\n        update(status: status, last_delivery_attempt: delivery.timestamp.to_f, held: status == \"Held\", hold_expiry: hold_expiry)\n        delivery\n      end\n\n      #\n      # Return all deliveries for this object\n      #\n      def deliveries\n        @deliveries ||= @database.select(\"deliveries\", where: { message_id: id }, order: :timestamp).map do |hash|\n          Delivery.new(self, hash)\n        end\n      end\n\n      #\n      # Return all the clicks for this object\n      #\n      def clicks\n        @clicks ||= begin\n          clicks = @database.select(\"clicks\", where: { message_id: id }, order: :timestamp)\n          if clicks.empty?\n            []\n          else\n            links = @database.select(\"links\", where: { id: clicks.map { |c| c[\"link_id\"].to_i } }).group_by { |l| l[\"id\"] }\n            clicks.map do |hash|\n              Click.new(hash, links[hash[\"link_id\"]].first)\n            end\n          end\n        end\n      end\n\n      #\n      # Return all the loads for this object\n      #\n      def loads\n        @loads ||= begin\n          loads = @database.select(\"loads\", where: { message_id: id }, order: :timestamp)\n          loads.map do |hash|\n            Load.new(hash)\n          end\n        end\n      end\n\n      #\n      # Return all activity entries\n      #\n      def activity_entries\n        @activity_entries ||= (deliveries + clicks + loads).sort_by(&:timestamp)\n      end\n\n      #\n      # Provide access to set and get acceptable attributes\n      #\n      def method_missing(name, value = nil, &block)\n        if @attributes.key?(name.to_s)\n          @attributes[name.to_s]\n        elsif name.to_s =~ /=\\z/\n          @attributes[name.to_s.gsub(\"=\", \"\").to_s] = value\n        end\n      end\n\n      def respond_to_missing?(name, include_private = false)\n        name = name.to_s.sub(/=\\z/, \"\")\n        @attributes.key?(name.to_s)\n      end\n\n      #\n      # Has this message been persisted to the database yet?\n      #\n      def persisted?\n        !@attributes[\"id\"].nil?\n      end\n\n      #\n      # Save this message\n      #\n      def save(queue_on_create: true)\n        save_raw_message\n        persisted? ? _update : _create(queue: queue_on_create)\n        self\n      end\n\n      #\n      # Update this message\n      #\n      def update(attributes_to_change)\n        @attributes = @attributes.merge(database.stringify_keys(attributes_to_change))\n        if persisted?\n          @database.update(\"messages\", attributes_to_change, where: { id: id })\n        else\n          _create\n        end\n      end\n\n      #\n      # Delete the message from the database\n      #\n      def delete\n        return unless persisted?\n\n        @database.delete(\"messages\", where: { id: id })\n      end\n\n      #\n      # Return the headers\n      #\n      def raw_headers\n        if raw_table\n          @raw_headers ||= @database.select(raw_table, where: { id: raw_headers_id }).first&.send(:[], \"data\") || \"\"\n        else\n          \"\"\n        end\n      end\n\n      #\n      # Return the full raw message body for this message.\n      #\n      def raw_body\n        if raw_table\n          @raw ||= @database.select(raw_table, where: { id: raw_body_id }).first&.send(:[], \"data\") || \"\"\n        else\n          \"\"\n        end\n      end\n\n      #\n      # Return the full raw message for this message\n      #\n      def raw_message\n        @raw_message ||= \"#{raw_headers}\\r\\n\\r\\n#{raw_body}\"\n      end\n\n      #\n      # Set the raw message ready for saving later\n      #\n      def raw_message=(raw)\n        @pending_raw_message = raw.force_encoding(\"BINARY\")\n      end\n\n      #\n      # Save the raw message to the database as appropriate\n      #\n      def save_raw_message\n        return unless @pending_raw_message\n\n        self.size = @pending_raw_message.bytesize\n        date = Time.now.utc.to_date\n        table_name, headers_id, body_id = @database.insert_raw_message(@pending_raw_message, date)\n        self.raw_table = table_name\n        self.raw_headers_id = headers_id\n        self.raw_body_id = body_id\n        @raw = nil\n        @raw_headers = nil\n        @headers = nil\n        @mail = nil\n        @pending_raw_message = nil\n        copy_attributes_from_raw_message\n        @database.query(\"UPDATE `#{@database.database_name}`.`raw_message_sizes` SET size = size + #{size} WHERE table_name = '#{table_name}'\")\n      end\n\n      #\n      # Is there a raw message?\n      #\n      def raw_message?\n        !!raw_table\n      end\n\n      #\n      # Return the plain body for this message\n      #\n      def plain_body\n        mail&.plain_body\n      end\n\n      #\n      # Return the HTML body for this message\n      #\n      def html_body\n        mail&.html_body\n      end\n\n      #\n      # Return the HTML body with any tracking links\n      #\n      def html_body_without_tracking_image\n        html_body.gsub(/<p class=['\"]ampimg['\"].*?<\\/p>/, \"\")\n      end\n\n      #\n      # Return all attachments for this message\n      #\n      def attachments\n        mail&.attachments || []\n      end\n\n      #\n      # Return the headers for this message\n      #\n      def headers\n        @headers ||= begin\n          mail = Mail.new(raw_headers)\n          mail.header.fields.each_with_object({}) do |field, hash|\n            hash[field.name.downcase] ||= []\n            begin\n              hash[field.name.downcase] << field.decoded\n            rescue Mail::Field::IncompleteParseError\n              # Never mind, move on to the next header\n            end\n          end\n        end\n      end\n\n      #\n      # Return the recipient domain for this message\n      #\n      def recipient_domain\n        rcpt_to&.split(\"@\")&.last\n      end\n\n      #\n      # Create a new item in the message queue for this message\n      #\n      def add_to_message_queue(**options)\n        QueuedMessage.create!({\n          message: self,\n          server_id: @database.server_id,\n          batch_key: batch_key,\n          domain: recipient_domain,\n          route_id: route_id\n        }.merge(options))\n      end\n\n      #\n      # Return a suitable batch key for this message\n      #\n      def batch_key\n        case scope\n        when \"outgoing\"\n          key = \"outgoing-\"\n          key += recipient_domain.to_s\n        when \"incoming\"\n          key = \"incoming-\"\n          key += \"rt:#{route_id}-ep:#{endpoint_id}-#{endpoint_type}\"\n        else\n          key = nil\n        end\n        key\n      end\n\n      #\n      # Return the queued message\n      #\n      def queued_message\n        @queued_message ||= id ? QueuedMessage.where(message_id: id, server_id: @database.server_id).first : nil\n      end\n\n      #\n      # Return the spam status\n      #\n      def spam_status\n        return \"NotChecked\" unless inspected\n\n        spam ? \"Spam\" : \"NotSpam\"\n      end\n\n      #\n      # Has this message been held?\n      #\n      def held?\n        status == \"Held\"\n      end\n\n      #\n      # Does this message have our DKIM header yet?\n      #\n      def has_outgoing_headers?\n        !!(raw_headers =~ /^X-Postal-MsgID:/i)\n      end\n\n      #\n      # Add dkim header\n      #\n      def add_outgoing_headers\n        headers = []\n        if domain\n          dkim = DKIMHeader.new(domain, raw_message)\n          headers << dkim.dkim_header\n        end\n        headers << \"X-Postal-MsgID: #{token}\"\n        append_headers(*headers)\n      end\n\n      #\n      # Append a header to the existing headers\n      #\n      def append_headers(*headers)\n        new_headers = headers.join(\"\\r\\n\")\n        new_headers = \"#{new_headers}\\r\\n#{raw_headers}\"\n        @database.update(raw_table, { data: new_headers }, where: { id: raw_headers_id })\n        @raw_headers = new_headers\n        @raw_message = nil\n        @headers = nil\n      end\n\n      #\n      # Return a suitable\n      #\n      def webhook_hash\n        @webhook_hash ||= {\n          id: id,\n          token: token,\n          direction: scope,\n          message_id: message_id,\n          to: rcpt_to,\n          from: mail_from,\n          subject: subject,\n          timestamp: timestamp.to_f,\n          spam_status: spam_status,\n          tag: tag\n        }\n      end\n\n      #\n      # Mark this message as bounced\n      #\n      def bounce!(bounce_message)\n        create_delivery(\"Bounced\", details: \"We've received a bounce message for this e-mail. See <msg:#{bounce_message.id}> for details.\")\n\n        WebhookRequest.trigger(server, \"MessageBounced\", {\n          original_message: webhook_hash,\n          bounce: bounce_message.webhook_hash\n        })\n      end\n\n      #\n      # Should bounces be sent for this message?\n      #\n      def send_bounces?\n        !bounce && mail_from.present?\n      end\n\n      #\n      # Add a load for this message\n      #\n      def create_load(request)\n        update(\"loaded\" => Time.now.to_f) if loaded.nil?\n        database.insert(:loads, { message_id: id, ip_address: request.ip, user_agent: request.user_agent, timestamp: Time.now.to_f })\n\n        WebhookRequest.trigger(server, \"MessageLoaded\", {\n          message: webhook_hash,\n          ip_address: request.ip,\n          user_agent: request.user_agent\n        })\n      end\n\n      #\n      # Create a new link\n      #\n      def create_link(url)\n        hash = Digest::SHA1.hexdigest(url.to_s)\n        token = SecureRandom.alphanumeric(16)\n        database.insert(:links, { message_id: id, hash: hash, url: url, timestamp: Time.now.to_f, token: token })\n        token\n      end\n\n      #\n      # Return a message object that this message is a reply to\n      #\n      def original_messages\n        return nil unless bounce\n\n        other_message_ids = raw_message.scan(/\\X-Postal-MsgID:\\s*([a-z0-9]+)/i).flatten\n        if other_message_ids.empty?\n          []\n        else\n          database.messages(where: { token: other_message_ids })\n        end\n      end\n\n      #\n      # Was thsi message sent to a return path?\n      #\n      def rcpt_to_return_path?\n        !!(rcpt_to =~ /@#{Regexp.escape(Postal::Config.dns.custom_return_path_prefix)}\\./)\n      end\n\n      #\n      # Inspect this message\n      #\n      def inspect_message\n        result = MessageInspection.scan(self, scope&.to_sym)\n\n        # Update the messages table with the results of our inspection\n        update(inspected: true, spam_score: result.spam_score, threat: result.threat, threat_details: result.threat_message)\n\n        # Add any spam details into the spam checks database\n        database.insert_multi(:spam_checks, [:message_id, :code, :score, :description], result.spam_checks.map { |d| [id, d.code, d.score, d.description] })\n\n        # Return the result\n        result\n      end\n\n      #\n      # Return all spam checks for this message\n      #\n      def spam_checks\n        @spam_checks ||= database.select(:spam_checks, where: { message_id: id })\n      end\n\n      #\n      # Cancel the hold on this message\n      #\n      def cancel_hold\n        return unless status == \"Held\"\n\n        create_delivery(\"HoldCancelled\", details: \"The hold on this message has been removed without action.\")\n      end\n\n      #\n      # Parse the contents of this message\n      #\n      def parse_content\n        parse_result = Postal::MessageParser.new(self)\n        if parse_result.actioned?\n          # Somethign was changed, update the raw message\n          @database.update(raw_table, { data: parse_result.new_body }, where: { id: raw_body_id })\n          @database.update(raw_table, { data: parse_result.new_headers }, where: { id: raw_headers_id })\n          @raw = parse_result.new_body\n          @raw_headers = parse_result.new_headers\n          @raw_message = nil\n        end\n        update(\"parsed\" => 1, \"tracked_links\" => parse_result.tracked_links, \"tracked_images\" => parse_result.tracked_images)\n      end\n\n      #\n      # Has this message been parsed?\n      #\n      def parsed?\n        parsed == 1\n      end\n\n      #\n      # Should this message be parsed?\n      #\n      def should_parse?\n        parsed? == false && headers[\"x-amp\"] != \"skip\"\n      end\n\n      private\n\n      def _update\n        @database.update(\"messages\", @attributes.except(:id), where: { id: @attributes[\"id\"] })\n      end\n\n      def _create(queue: true)\n        self.timestamp = Time.now.to_f if timestamp.blank?\n        self.status = \"Pending\" if status.blank?\n        self.token = SecureRandom.alphanumeric(16) if token.blank?\n        last_id = @database.insert(\"messages\", @attributes.except(:id))\n        @attributes[\"id\"] = last_id\n        @database.statistics.increment_all(timestamp, scope)\n        Statistic.global.increment!(:total_messages)\n        Statistic.global.increment!(\"total_#{scope}\".to_sym)\n        add_to_message_queue if queue\n      end\n\n      def mail\n        # This version of mail is only used for accessing the bodies.\n        @mail ||= raw_message? ? Mail.new(raw_message) : nil\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migration.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    class Migration\n\n      def initialize(database)\n        @database = database\n      end\n\n      def up\n      end\n\n      def self.run(database, start_from: database.schema_version, silent: false)\n        files = Dir[Rails.root.join(\"lib\", \"postal\", \"message_db\", \"migrations\", \"*.rb\")]\n        files = files.map do |f|\n          id, name = f.split(\"/\").last.split(\"_\", 2)\n          [id.to_i, name]\n        end.sort_by(&:first)\n\n        latest_version = files.last.first\n        if latest_version <= start_from\n          puts \"Nothing to do\" unless silent\n          return false\n        end\n\n        unless silent\n          puts \"\\e[32mMigrating #{database.database_name} from version #{start_from} => #{files.last.first}\\e[0m\"\n        end\n\n        files.each do |version, file|\n          klass_name = file.gsub(/\\.rb\\z/, \"\").camelize\n          next if start_from >= version\n\n          puts \"\\e[45m++ Migrating #{klass_name} (#{version})\\e[0m\" unless silent\n          require \"postal/message_db/migrations/#{version.to_s.rjust(2, '0')}_#{file}\"\n          klass = Postal::MessageDB::Migrations.const_get(klass_name)\n          instance = klass.new(database)\n          instance.up\n          database.insert(:migrations, version: version)\n        end\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/01_create_migrations.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class CreateMigrations < Postal::MessageDB::Migration\n\n        def up\n          @database.provisioner.create_table(:migrations,\n                                             columns: {\n                                               version: \"int(11) NOT NULL\"\n                                             },\n                                             primary_key: \"`version`\")\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/02_create_messages.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class CreateMessages < Postal::MessageDB::Migration\n\n        def up\n          @database.provisioner.create_table(:messages,\n                                             columns: {\n                                               id: \"int(11) NOT NULL AUTO_INCREMENT\",\n                                               token: \"varchar(255) DEFAULT NULL\",\n                                               scope: \"varchar(10) DEFAULT NULL\",\n                                               rcpt_to: \"varchar(255) DEFAULT NULL\",\n                                               mail_from: \"varchar(255) DEFAULT NULL\",\n                                               subject: \"varchar(255) DEFAULT NULL\",\n                                               message_id: \"varchar(255) DEFAULT NULL\",\n                                               timestamp: \"decimal(18,6) DEFAULT NULL\",\n                                               route_id: \"int(11) DEFAULT NULL\",\n                                               domain_id: \"int(11) DEFAULT NULL\",\n                                               credential_id: \"int(11) DEFAULT NULL\",\n                                               status: \"varchar(255) DEFAULT NULL\",\n                                               held: \"tinyint(1) DEFAULT 0\",\n                                               size: \"varchar(255) DEFAULT NULL\",\n                                               last_delivery_attempt: \"decimal(18,6) DEFAULT NULL\",\n                                               raw_table: \"varchar(255) DEFAULT NULL\",\n                                               raw_body_id: \"int(11) DEFAULT NULL\",\n                                               raw_headers_id: \"int(11) DEFAULT NULL\",\n                                               inspected: \"tinyint(1) DEFAULT 0\",\n                                               spam: \"tinyint(1) DEFAULT 0\",\n                                               spam_score: \"decimal(8,2) DEFAULT 0\",\n                                               threat: \"tinyint(1) DEFAULT 0\",\n                                               threat_details: \"varchar(255) DEFAULT NULL\",\n                                               bounce: \"tinyint(1) DEFAULT 0\",\n                                               bounce_for_id: \"int(11) DEFAULT 0\",\n                                               tag: \"varchar(255) DEFAULT NULL\",\n                                               loaded: \"decimal(18,6) DEFAULT NULL\",\n                                               clicked: \"decimal(18,6) DEFAULT NULL\",\n                                               received_with_ssl: \"tinyint(1) DEFAULT NULL\"\n                                             },\n                                             indexes: {\n                                               on_message_id: \"`message_id`(8)\",\n                                               on_token: \"`token`(6)\",\n                                               on_bounce_for_id: \"`bounce_for_id`\",\n                                               on_held: \"`held`\",\n                                               on_scope_and_status: \"`scope`(1), `spam`, `status`(6), `timestamp`\",\n                                               on_scope_and_tag: \"`scope`(1), `spam`, `tag`(8), `timestamp`\",\n                                               on_scope_and_spam: \"`scope`(1), `spam`, `timestamp`\",\n                                               on_scope_and_thr_status: \"`scope`(1), `threat`, `status`(6), `timestamp`\",\n                                               on_scope_and_threat: \"`scope`(1), `threat`, `timestamp`\",\n                                               on_rcpt_to: \"`rcpt_to`(12), `timestamp`\",\n                                               on_mail_from: \"`mail_from`(12), `timestamp`\",\n                                               on_raw_table: \"`raw_table`(14)\"\n                                             })\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/03_create_deliveries.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class CreateDeliveries < Postal::MessageDB::Migration\n\n        def up\n          @database.provisioner.create_table(:deliveries,\n                                             columns: {\n                                               id: \"int(11) NOT NULL AUTO_INCREMENT\",\n                                               message_id: \"int(11) DEFAULT NULL\",\n                                               status: \"varchar(255) DEFAULT NULL\",\n                                               code: \"int(11) DEFAULT NULL\",\n                                               output: \"varchar(512) DEFAULT NULL\",\n                                               details: \"varchar(512) DEFAULT NULL\",\n                                               sent_with_ssl: \"tinyint(1) DEFAULT 0\",\n                                               log_id: \"varchar(100) DEFAULT NULL\",\n                                               timestamp: \"decimal(18,6) DEFAULT NULL\"\n                                             },\n                                             indexes: {\n                                               on_message_id: \"`message_id`\"\n                                             })\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/04_create_live_stats.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class CreateLiveStats < Postal::MessageDB::Migration\n\n        def up\n          @database.provisioner.create_table(:live_stats,\n                                             columns: {\n                                               type: \"varchar(20) NOT NULL\",\n                                               minute: \"int(11) NOT NULL\",\n                                               count: \"int(11) DEFAULT NULL\",\n                                               timestamp: \"decimal(18,6) DEFAULT NULL\"\n                                             },\n                                             primary_key: \"`minute`, `type`(8)\")\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/05_create_raw_message_sizes.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class CreateRawMessageSizes < Postal::MessageDB::Migration\n\n        def up\n          @database.provisioner.create_table(:raw_message_sizes,\n                                             columns: {\n                                               id: \"int(11) NOT NULL AUTO_INCREMENT\",\n                                               table_name: \"varchar(255) DEFAULT NULL\",\n                                               size: \"bigint DEFAULT NULL\"\n                                             },\n                                             indexes: {\n                                               on_table_name: \"`table_name`(14)\"\n                                             })\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/06_create_clicks.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class CreateClicks < Postal::MessageDB::Migration\n\n        def up\n          @database.provisioner.create_table(:clicks,\n                                             columns: {\n                                               id: \"int(11) NOT NULL AUTO_INCREMENT\",\n                                               message_id: \"int(11) DEFAULT NULL\",\n                                               link_id: \"int(11) DEFAULT NULL\",\n                                               ip_address: \"varchar(255) DEFAULT NULL\",\n                                               country: \"varchar(255) DEFAULT NULL\",\n                                               city: \"varchar(255) DEFAULT NULL\",\n                                               user_agent: \"varchar(255) DEFAULT NULL\",\n                                               timestamp: \"decimal(18,6) DEFAULT NULL\"\n                                             },\n                                             indexes: {\n                                               on_message_id: \"`message_id`\",\n                                               on_link_id: \"`link_id`\"\n                                             })\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/07_create_loads.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class CreateLoads < Postal::MessageDB::Migration\n\n        def up\n          @database.provisioner.create_table(:loads,\n                                             columns: {\n                                               id: \"int(11) NOT NULL AUTO_INCREMENT\",\n                                               message_id: \"int(11) DEFAULT NULL\",\n                                               ip_address: \"varchar(255) DEFAULT NULL\",\n                                               country: \"varchar(255) DEFAULT NULL\",\n                                               city: \"varchar(255) DEFAULT NULL\",\n                                               user_agent: \"varchar(255) DEFAULT NULL\",\n                                               timestamp: \"decimal(18,6) DEFAULT NULL\"\n                                             },\n                                             indexes: {\n                                               on_message_id: \"`message_id`\"\n                                             })\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/08_create_stats.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class CreateStats < Postal::MessageDB::Migration\n\n        def up\n          [:hourly, :daily, :monthly, :yearly].each do |table_name|\n            @database.provisioner.create_table(\"stats_#{table_name}\",\n                                               columns: {\n                                                 id: \"int(11) NOT NULL AUTO_INCREMENT\",\n                                                 time: \"int(11) DEFAULT NULL\",\n                                                 incoming: \"bigint DEFAULT NULL\",\n                                                 outgoing: \"bigint DEFAULT NULL\",\n                                                 spam: \"bigint DEFAULT NULL\",\n                                                 bounces: \"bigint DEFAULT NULL\",\n                                                 held: \"bigint DEFAULT NULL\"\n                                               },\n                                               unique_indexes: {\n                                                 on_time: \"`time`\"\n                                               })\n          end\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/09_create_links.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class CreateLinks < Postal::MessageDB::Migration\n\n        def up\n          @database.provisioner.create_table(:links,\n                                             columns: {\n                                               id: \"int(11) NOT NULL AUTO_INCREMENT\",\n                                               message_id: \"int(11) DEFAULT NULL\",\n                                               token: \"varchar(255) DEFAULT NULL\",\n                                               hash: \"varchar(255) DEFAULT NULL\",\n                                               url: \"varchar(255) DEFAULT NULL\",\n                                               timestamp: \"decimal(18,6) DEFAULT NULL\"\n                                             },\n                                             indexes: {\n                                               on_message_id: \"`message_id`\",\n                                               on_token: \"`token`(8)\"\n                                             })\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/10_create_spam_checks.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class CreateSpamChecks < Postal::MessageDB::Migration\n\n        def up\n          @database.provisioner.create_table(:spam_checks,\n                                             columns: {\n                                               id: \"int(11) NOT NULL AUTO_INCREMENT\",\n                                               message_id: \"int(11) DEFAULT NULL\",\n                                               score: \"decimal(8,2) DEFAULT NULL\",\n                                               code: \"varchar(255) DEFAULT NULL\",\n                                               description: \"varchar(255) DEFAULT NULL\"\n                                             },\n                                             indexes: {\n                                               on_message_id: \"`message_id`\",\n                                               on_code: \"`code`(8)\"\n                                             })\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/11_add_time_to_deliveries.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class AddTimeToDeliveries < Postal::MessageDB::Migration\n\n        def up\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`deliveries` ADD COLUMN `time` decimal(8,2)\")\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/12_add_hold_expiry.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class AddHoldExpiry < Postal::MessageDB::Migration\n\n        def up\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `hold_expiry` decimal(18,6)\")\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/13_add_index_to_message_status.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class AddIndexToMessageStatus < Postal::MessageDB::Migration\n\n        def up\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`messages` ADD INDEX `on_status` (`status`(8)) USING BTREE\")\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/14_create_suppressions.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class CreateSuppressions < Postal::MessageDB::Migration\n\n        def up\n          @database.provisioner.create_table(:suppressions,\n                                             columns: {\n                                               id: \"int(11) NOT NULL AUTO_INCREMENT\",\n                                               type: \"varchar(255) DEFAULT NULL\",\n                                               address: \"varchar(255) DEFAULT NULL\",\n                                               reason: \"varchar(255) DEFAULT NULL\",\n                                               timestamp: \"decimal(18,6) DEFAULT NULL\",\n                                               keep_until: \"decimal(18,6) DEFAULT NULL\"\n                                             },\n                                             indexes: {\n                                               on_address: \"`address`(6)\",\n                                               on_keep_until: \"`keep_until`\"\n                                             })\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/15_create_webhook_requests.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class CreateWebhookRequests < Postal::MessageDB::Migration\n\n        def up\n          @database.provisioner.create_table(:webhook_requests,\n                                             columns: {\n                                               id: \"int(11) NOT NULL AUTO_INCREMENT\",\n                                               uuid: \"varchar(255) DEFAULT NULL\",\n                                               event: \"varchar(255) DEFAULT NULL\",\n                                               attempt: \"int(11) DEFAULT NULL\",\n                                               timestamp: \"decimal(18,6) DEFAULT NULL\",\n                                               status_code: \"int(1) DEFAULT NULL\",\n                                               body: \"text DEFAULT NULL\",\n                                               payload: \"text DEFAULT NULL\",\n                                               will_retry: \"tinyint DEFAULT NULL\"\n                                             },\n                                             indexes: {\n                                               on_uuid: \"`uuid`(8)\",\n                                               on_event: \"`event`(8)\",\n                                               on_timestamp: \"`timestamp`\"\n                                             })\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/16_add_url_and_hook_to_webhooks.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class AddUrlAndHookToWebhooks < Postal::MessageDB::Migration\n\n        def up\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`webhook_requests` ADD COLUMN `url` varchar(255)\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`webhook_requests` ADD COLUMN `webhook_id` int(11)\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`webhook_requests` ADD INDEX `on_webhook_id` (`webhook_id`) USING BTREE\")\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/17_add_replaced_link_count_to_messages.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class AddReplacedLinkCountToMessages < Postal::MessageDB::Migration\n\n        def up\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `tracked_links` int(11) DEFAULT 0\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `tracked_images` int(11) DEFAULT 0\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `parsed` tinyint DEFAULT 0\")\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/18_add_endpoints_to_messages.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class AddEndpointsToMessages < Postal::MessageDB::Migration\n\n        def up\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `endpoint_id` int(11), ADD COLUMN `endpoint_type` varchar(255)\")\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/19_convert_database_to_utf8mb4.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class ConvertDatabaseToUtf8mb4 < Postal::MessageDB::Migration\n\n        def up\n          @database.query(\"ALTER DATABASE `#{@database.database_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`clicks` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`deliveries` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`links` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`live_stats` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`loads` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`messages` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`migrations` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`raw_message_sizes` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`spam_checks` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`stats_daily` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`stats_hourly` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`stats_monthly` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`stats_yearly` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`suppressions` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`webhook_requests` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci\")\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/migrations/20_increase_links_url_size.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    module Migrations\n      class IncreaseLinksUrlSize < Postal::MessageDB::Migration\n\n        def up\n          @database.query(\"ALTER TABLE `#{@database.database_name}`.`links` MODIFY `url` TEXT\")\n        end\n\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/provisioner.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    class Provisioner\n\n      def initialize(database)\n        @database = database\n      end\n\n      #\n      # Provisions a new database\n      #\n      def provision\n        drop\n        create\n        migrate(silent: true)\n      end\n\n      #\n      # Migrate this database\n      #\n      def migrate(start_from: @database.schema_version, silent: false)\n        Postal::MessageDB::Migration.run(@database, start_from: start_from, silent: silent)\n      end\n\n      #\n      # Does a database already exist?\n      #\n      def exists?\n        !!@database.query(\"SELECT schema_name FROM `information_schema`.`schemata` WHERE schema_name = '#{@database.database_name}'\").first\n      end\n\n      #\n      # Creates a new empty database\n      #\n      def create\n        @database.query(\"CREATE DATABASE `#{@database.database_name}` CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;\")\n        true\n      rescue Mysql2::Error => e\n        e.message =~ /database exists/ ? false : raise\n      end\n\n      #\n      # Drops the whole message database\n      #\n      def drop\n        @database.query(\"DROP DATABASE `#{@database.database_name}`;\")\n        true\n      rescue Mysql2::Error => e\n        e.message =~ /doesn't exist/ ? false : raise\n      end\n\n      #\n      # Create a new table\n      #\n      def create_table(table_name, options)\n        @database.query(create_table_query(table_name, options))\n      end\n\n      #\n      # Drop a table\n      #\n      def drop_table(table_name)\n        @database.query(\"DROP TABLE `#{@database.database_name}`.`#{table_name}`\")\n      end\n\n      #\n      # Clean the database. This really only useful in development & testing\n      # environment and can be quite dangerous in production.\n      #\n      def clean\n        %w[clicks deliveries links live_stats loads messages\n           raw_message_sizes spam_checks stats_daily stats_hourly\n           stats_monthly stats_yearly suppressions webhook_requests].each do |table|\n          @database.query(\"TRUNCATE `#{@database.database_name}`.`#{table}`\")\n        end\n      end\n\n      #\n      # Creates a new empty raw message table for the given date. Returns nothing.\n      #\n      def create_raw_table(table)\n        @database.query(create_table_query(table, columns: {\n            id: \"int(11) NOT NULL AUTO_INCREMENT\",\n            data: \"longblob DEFAULT NULL\",\n            next: \"int(11) DEFAULT NULL\"\n          }))\n        @database.query(\"INSERT INTO `#{@database.database_name}`.`raw_message_sizes` (table_name, size) VALUES ('#{table}', 0)\")\n      rescue Mysql2::Error => e\n        # Don't worry if the table already exists, another thread has already run this code.\n        raise unless e.message =~ /already exists/\n      end\n\n      #\n      # Return a list of raw message tables that are older than the given date\n      #\n      def raw_tables(max_age = 30)\n        earliest_date = max_age ? Time.now.utc.to_date - max_age : nil\n        [].tap do |tables|\n          @database.query(\"SHOW TABLES FROM `#{@database.database_name}` LIKE 'raw-%'\").each do |tbl|\n            tbl_name = tbl.to_a.first.last\n            date = Date.parse(tbl_name.gsub(/\\Araw-/, \"\"))\n            if earliest_date.nil? || date < earliest_date\n              tables << tbl_name\n            end\n          end\n        end.sort\n      end\n\n      #\n      # Tidy all messages\n      #\n      def remove_raw_tables_older_than(max_age = 30)\n        raw_tables(max_age).each do |table|\n          remove_raw_table(table)\n        end\n      end\n\n      #\n      # Remove a raw message table\n      #\n      def remove_raw_table(table)\n        @database.query(\"UPDATE `#{@database.database_name}`.`messages` SET raw_table = NULL, raw_headers_id = NULL, raw_body_id = NULL, size = NULL WHERE raw_table = '#{table}'\")\n        @database.query(\"DELETE FROM `#{@database.database_name}`.`raw_message_sizes` WHERE table_name = '#{table}'\")\n        drop_table(table)\n      end\n\n      #\n      # Remove messages from the messages table that are too old to retain\n      #\n      def remove_messages(max_age = 60)\n        time = (Time.now.utc.to_date - max_age.days).to_time.end_of_day\n        return unless newest_message_to_remove = @database.select(:messages, where: { timestamp: { less_than_or_equal_to: time.to_f } }, limit: 1, order: :id, direction: \"DESC\", fields: [:id]).first\n\n        id = newest_message_to_remove[\"id\"]\n        @database.query(\"DELETE FROM `#{@database.database_name}`.`clicks` WHERE `message_id` <= #{id}\")\n        @database.query(\"DELETE FROM `#{@database.database_name}`.`loads` WHERE `message_id` <= #{id}\")\n        @database.query(\"DELETE FROM `#{@database.database_name}`.`deliveries` WHERE `message_id` <= #{id}\")\n        @database.query(\"DELETE FROM `#{@database.database_name}`.`spam_checks` WHERE `message_id` <= #{id}\")\n        @database.query(\"DELETE FROM `#{@database.database_name}`.`messages` WHERE `id` <= #{id}\")\n      end\n\n      #\n      # Remove raw message tables in order order until size is under the given size (given in MB)\n      #\n      def remove_raw_tables_until_less_than_size(size)\n        tables = raw_tables(nil)\n        tables_removed = []\n        until @database.total_size <= size\n          table = tables.shift\n          tables_removed << table\n          remove_raw_table(table)\n        end\n        tables_removed\n      end\n\n      private\n\n      #\n      # Build a query to load a table\n      #\n      def create_table_query(table_name, options)\n        String.new.tap do |s|\n          s << \"CREATE TABLE `#{@database.database_name}`.`#{table_name}` (\"\n          s << options[:columns].map do |column_name, column_options|\n            \"`#{column_name}` #{column_options}\"\n          end.join(\", \")\n          if options[:indexes]\n            s << \", \"\n            s << options[:indexes].map do |index_name, index_options|\n              \"KEY `#{index_name}` (#{index_options}) USING BTREE\"\n            end.join(\", \")\n          end\n          if options[:unique_indexes]\n            s << \", \"\n            s << options[:unique_indexes].map do |index_name, index_options|\n              \"UNIQUE KEY `#{index_name}` (#{index_options})\"\n            end.join(\", \")\n          end\n          if options[:primary_key]\n            s << \", PRIMARY KEY (#{options[:primary_key]})\"\n          else\n            s << \", PRIMARY KEY (`id`)\"\n          end\n\n          s << \") ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;\"\n        end\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/statistics.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    class Statistics\n\n      def initialize(database)\n        @database = database\n      end\n\n      STATS_GAPS = { hourly: :hour, daily: :day, monthly: :month, yearly: :year }.freeze\n      COUNTERS = [:incoming, :outgoing, :spam, :bounces, :held].freeze\n\n      #\n      # Increment an appropriate counter\n      #\n      def increment_one(type, field, time = Time.now)\n        time = time.utc\n        initial_values = COUNTERS.map do |c|\n          field.to_sym == c ? 1 : 0\n        end\n\n        time_i = time.send(\"beginning_of_#{STATS_GAPS[type]}\").utc.to_i\n        sql_query = \"INSERT INTO `#{@database.database_name}`.`stats_#{type}` (time, #{COUNTERS.join(', ')})\"\n        sql_query << \" VALUES (#{time_i}, #{initial_values.join(', ')})\"\n        sql_query << \" ON DUPLICATE KEY UPDATE #{field} = #{field} + 1\"\n        @database.query(sql_query)\n      end\n\n      #\n      # Increment all stats counters\n      #\n      def increment_all(time, field)\n        STATS_GAPS.each_key do |type|\n          increment_one(type, field, time)\n        end\n      end\n\n      #\n      # Get a statistic (or statistics)\n      #\n      def get(type, counters, start_date = Time.now, quantity = 10)\n        start_date = start_date.utc\n        items = quantity.times.each_with_object({}) do |i, hash|\n          hash[(start_date - i.send(STATS_GAPS[type])).send(\"beginning_of_#{STATS_GAPS[type]}\").utc] = counters.each_with_object({}) do |c, h|\n            h[c] = 0\n          end\n        end\n        @database.select(\"stats_#{type}\", where: { time: items.keys.map(&:to_i) }, fields: [:time] | counters).each do |data|\n          time = Time.zone.at(data.delete(\"time\"))\n          data.each do |key, value|\n            items[time][key.to_sym] = value\n          end\n        end\n        items.to_a.reverse\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/suppression_list.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    class SuppressionList\n\n      def initialize(database)\n        @database = database\n      end\n\n      def add(type, address, options = {})\n        keep_until = (options[:days] || Postal::Config.postal.default_suppression_list_automatic_removal_days).days.from_now.to_f\n        if existing = @database.select(\"suppressions\", where: { type: type, address: address }, limit: 1).first\n          reason = options[:reason] || existing[\"reason\"]\n          @database.update(\"suppressions\", { reason: reason, keep_until: keep_until }, where: { id: existing[\"id\"] })\n        else\n          @database.insert(\"suppressions\", { type: type, address: address, reason: options[:reason], timestamp: Time.now.to_f, keep_until: keep_until })\n        end\n        true\n      end\n\n      def get(type, address)\n        @database.select(\"suppressions\", where: { type: type, address: address, keep_until: { greater_than_or_equal_to: Time.now.to_f } }, limit: 1).first\n      end\n\n      def all_with_pagination(page)\n        @database.select_with_pagination(:suppressions, page, order: :timestamp, direction: \"desc\")\n      end\n\n      def remove(type, address)\n        @database.delete(\"suppressions\", where: { type: type, address: address }).positive?\n      end\n\n      def prune\n        @database.delete(\"suppressions\", where: { keep_until: { less_than: Time.now.to_f } }) || 0\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_db/webhooks.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageDB\n    class Webhooks\n\n      def initialize(database)\n        @database = database\n      end\n\n      def record(attributes = {})\n        @database.insert(:webhook_requests, attributes)\n      end\n\n      def list(page = 1)\n        result = @database.select_with_pagination(:webhook_requests, page, order: :timestamp, direction: \"desc\")\n        result[:records] = result[:records].map { |i| Request.new(i) }\n        result\n      end\n\n      def find(uuid)\n        request = @database.select(:webhook_requests, where: { uuid: uuid }).first || raise(RequestNotFound, \"No request found with UUID '#{uuid}'\")\n        Request.new(request)\n      end\n\n      def prune\n        return unless last = @database.select(:webhook_requests, where: { timestamp: { less_than: 10.days.ago.to_f } }, order: \"timestamp\", direction: \"desc\", limit: 1, fields: [\"id\"]).first\n\n        @database.delete(:webhook_requests, where: { id: { less_than_or_equal_to: last[\"id\"] } })\n      end\n\n      class RequestNotFound < Postal::Error\n      end\n\n      class Request\n\n        def initialize(attributes)\n          @attributes = attributes\n        end\n\n        def [](name)\n          @attributes[name.to_s]\n        end\n\n        def timestamp\n          Time.zone.at(@attributes[\"timestamp\"])\n        end\n\n        def event\n          @attributes[\"event\"]\n        end\n\n        def status_code\n          @attributes[\"status_code\"]\n        end\n\n        def url\n          @attributes[\"url\"]\n        end\n\n        def uuid\n          @attributes[\"uuid\"]\n        end\n\n        def payload\n          @attributes[\"payload\"]\n        end\n\n        def pretty_payload\n          @pretty_payload ||= begin\n            json = JSON.parse(payload)\n            JSON.pretty_unparse(json)\n          end\n        end\n\n        def body\n          @attributes[\"body\"]\n        end\n\n        def attempt\n          @attributes[\"attempt\"]\n        end\n\n        def will_retry?\n          @attributes[\"will_retry\"] == 1\n        end\n\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_inspection.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  class MessageInspection\n\n    attr_reader :message\n    attr_reader :scope\n    attr_reader :spam_checks\n    attr_accessor :threat\n    attr_accessor :threat_message\n\n    def initialize(message, scope)\n      @message = message\n      @scope = scope\n      @spam_checks = []\n      @threat = false\n    end\n\n    def spam_score\n      return 0 if @spam_checks.empty?\n\n      @spam_checks.sum(&:score)\n    end\n\n    def scan\n      MessageInspector.inspectors.each do |inspector|\n        inspector.inspect_message(self)\n      end\n    end\n\n    class << self\n\n      def scan(message, scope)\n        inspection = new(message, scope)\n        inspection.scan\n        inspection\n      end\n\n    end\n\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_inspector.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  class MessageInspector\n\n    def initialize(config)\n      @config = config\n    end\n\n    # Inspect a message and update the inspection with the results\n    # as appropriate.\n    def inspect_message(message, scope, inspection)\n    end\n\n    private\n\n    def logger\n      Postal.logger\n    end\n\n    class << self\n\n      # Return an array of all inspectors that are available for this\n      # installation.\n      def inspectors\n        [].tap do |inspectors|\n          if Postal::Config.rspamd.enabled?\n            inspectors << MessageInspectors::Rspamd.new(Postal::Config.rspamd)\n          elsif Postal::Config.spamd.enabled?\n            inspectors << MessageInspectors::SpamAssassin.new(Postal::Config.spamd)\n          end\n\n          if Postal::Config.clamav.enabled?\n            inspectors << MessageInspectors::Clamav.new(Postal::Config.clamav)\n          end\n        end\n      end\n\n    end\n\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_inspectors/clamav.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageInspectors\n    class Clamav < MessageInspector\n\n      def inspect_message(inspection)\n        raw_message = inspection.message.raw_message\n\n        data = nil\n        Timeout.timeout(10) do\n          tcp_socket = TCPSocket.new(@config.host, @config.port)\n          tcp_socket.write(\"zINSTREAM\\0\")\n          tcp_socket.write([raw_message.bytesize].pack(\"N\"))\n          tcp_socket.write(raw_message)\n          tcp_socket.write([0].pack(\"N\"))\n          tcp_socket.close_write\n          data = tcp_socket.read\n        end\n\n        if data && data =~ /\\Astream:\\s+(.*?)[\\s\\0]+?/\n          if ::Regexp.last_match(1).upcase == \"OK\"\n            inspection.threat = false\n            inspection.threat_message = \"No threats found\"\n          else\n            inspection.threat = true\n            inspection.threat_message = ::Regexp.last_match(1)\n          end\n        else\n          inspection.threat = false\n          inspection.threat_message = \"Could not scan message\"\n        end\n      rescue Timeout::Error\n        inspection.threat = false\n        inspection.threat_message = \"Timed out scanning for threats\"\n      rescue StandardError => e\n        logger.error \"Error talking to clamav: #{e.class} (#{e.message})\"\n        logger.error e.backtrace[0, 5]\n        inspection.threat = false\n        inspection.threat_message = \"Error when scanning for threats\"\n      ensure\n        begin\n          tcp_socket.close\n        rescue StandardError\n          nil\n        end\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_inspectors/rspamd.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"net/http\"\n\nmodule Postal\n  module MessageInspectors\n    class Rspamd < MessageInspector\n\n      class Error < StandardError\n      end\n\n      def inspect_message(inspection)\n        response = request(inspection.message, inspection.scope)\n        response = JSON.parse(response.body)\n        return unless response[\"symbols\"].is_a?(Hash)\n\n        response[\"symbols\"].each_value do |symbol|\n          next if symbol[\"description\"].blank?\n\n          inspection.spam_checks << SpamCheck.new(symbol[\"name\"], symbol[\"score\"], symbol[\"description\"])\n        end\n      rescue Error => e\n        inspection.spam_checks << SpamCheck.new(\"ERROR\", 0, e.message)\n      end\n\n      private\n\n      def request(message, scope)\n        http = Net::HTTP.new(@config.host, @config.port)\n        http.use_ssl = true if @config.ssl\n        http.read_timeout = 10\n        http.open_timeout = 10\n\n        raw_message = message.raw_message\n\n        request = Net::HTTP::Post.new(\"/checkv2\")\n        request.body = raw_message\n        request[\"Content-Length\"] = raw_message.bytesize.to_s\n        request[\"Password\"] = @config.password if @config.password\n        request[\"Flags\"] = @config.flags if @config.flags\n        request[\"User-Agent\"] = \"Postal\"\n        request[\"Deliver-To\"] = message.rcpt_to\n        request[\"From\"] = message.mail_from\n        request[\"Rcpt\"] = message.rcpt_to\n        request[\"Queue-Id\"] = message.token\n\n        if scope == :outgoing\n          request[\"User\"] = \"\"\n          # We don't actually know the IP but an empty input here will\n          # still trigger rspamd to treat this as an outbound email\n          # and disable certain checks.\n          # https://rspamd.com/doc/tutorials/scanning_outbound.html\n          request[\"Ip\"] = \"\"\n        end\n\n        response = nil\n        begin\n          response = http.request(request)\n        rescue StandardError => e\n          logger.error \"Error talking to rspamd: #{e.class} (#{e.message})\"\n          logger.error e.backtrace[0, 5]\n\n          raise Error, \"Error when scanning with rspamd (#{e.class})\"\n        end\n\n        unless response.is_a?(Net::HTTPOK)\n          logger.info \"Got #{response.code} status from rspamd, wanted 200\"\n          raise Error, \"Error when scanning with rspamd (got #{response.code})\"\n        end\n\n        response\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_inspectors/spam_assassin.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  module MessageInspectors\n    class SpamAssassin < MessageInspector\n\n      EXCLUSIONS = {\n        outgoing: [\"NO_RECEIVED\", \"NO_RELAYS\", \"ALL_TRUSTED\", \"FREEMAIL_FORGED_REPLYTO\", \"RDNS_DYNAMIC\", \"CK_HELO_GENERIC\", /^SPF_/, /^HELO_/, /DKIM_/, /^RCVD_IN_/],\n        incoming: []\n      }.freeze\n\n      def inspect_message(inspection)\n        data = nil\n        raw_message = inspection.message.raw_message\n        Timeout.timeout(15) do\n          tcp_socket = TCPSocket.new(@config.host, @config.port)\n          tcp_socket.write(\"REPORT SPAMC/1.2\\r\\n\")\n          tcp_socket.write(\"Content-length: #{raw_message.bytesize}\\r\\n\")\n          tcp_socket.write(\"\\r\\n\")\n          tcp_socket.write(raw_message)\n          tcp_socket.close_write\n          data = tcp_socket.read\n        end\n\n        spam_checks = []\n        total = 0.0\n        rules = data ? data.split(/^---(.*)\\r?\\n/).last.split(/\\r?\\n/) : []\n        while line = rules.shift\n          if line =~ /\\A([- ]?[\\d.]+)\\s+(\\w+)\\s+(.*)/\n            total += ::Regexp.last_match(1).to_f\n            spam_checks << SpamCheck.new(::Regexp.last_match(2), ::Regexp.last_match(1).to_f, ::Regexp.last_match(3))\n          else\n            spam_checks.last.description << (\" \" + line.strip)\n          end\n        end\n\n        checks = spam_checks.reject { |s| EXCLUSIONS[inspection.scope].include?(s.code) }\n        checks.each do |check|\n          inspection.spam_checks << check\n        end\n      rescue Timeout::Error\n        inspection.spam_checks << SpamCheck.new(\"TIMEOUT\", 0, \"Timed out when scanning for spam\")\n      rescue StandardError => e\n        logger.error \"Error talking to spamd: #{e.class} (#{e.message})\"\n        logger.error e.backtrace[0, 5]\n        inspection.spam_checks << SpamCheck.new(\"ERROR\", 0, \"Error when scanning for spam\")\n      ensure\n        begin\n          tcp_socket.close\n        rescue StandardError\n          nil\n        end\n      end\n\n    end\n  end\nend\n"
  },
  {
    "path": "lib/postal/message_parser.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  class MessageParser\n\n    URL_REGEX = /(?<url>(?<protocol>https?):\\/\\/(?<domain>[A-Za-z0-9\\-.:]+)(?<path>\\/[A-Za-z0-9.\\/+?&\\-_%=~:;()\\[\\]#]*)?+)/\n\n    def initialize(message)\n      @message = message\n      @actioned = false\n      @tracked_links = 0\n      @tracked_images = 0\n      @domain = @message.server.track_domains.where(domain: @message.domain, dns_status: \"OK\").first\n\n      return unless @domain\n\n      @parsed_output = generate.split(\"\\r\\n\\r\\n\", 2)\n    end\n\n    attr_reader :tracked_links\n    attr_reader :tracked_images\n\n    def actioned?\n      @actioned || @tracked_links.positive? || @tracked_images.positive?\n    end\n\n    def new_body\n      @parsed_output[1]\n    end\n\n    def new_headers\n      @parsed_output[0]\n    end\n\n    private\n\n    def generate\n      @mail = Mail.new(@message.raw_message)\n      @original_message = @message.raw_message\n      if @mail.parts.empty?\n        if @mail.mime_type\n          if @mail.mime_type =~ /text\\/plain/\n            @mail.body = parse(@mail.body.decoded.dup, :text)\n            @mail.content_transfer_encoding = nil\n            @mail.charset = \"UTF-8\"\n          elsif @mail.mime_type =~ /text\\/html/\n            @mail.body = parse(@mail.body.decoded.dup, :html)\n            @mail.content_transfer_encoding = nil\n            @mail.charset = \"UTF-8\"\n          end\n        end\n      else\n        parse_parts(@mail.parts)\n      end\n      @mail.to_s\n    rescue StandardError => e\n      raise if Rails.env.development?\n\n      if defined?(Sentry)\n        Sentry.capture_exception(e)\n      end\n      @actioned = false\n      @tracked_links = 0\n      @tracked_images = 0\n      @original_message\n    end\n\n    def parse_parts(parts)\n      parts.each do |part|\n        case part.content_type\n        when /text\\/html/\n          part.body = parse(part.body.decoded.dup, :html)\n          part.content_transfer_encoding = nil\n          part.charset = \"UTF-8\"\n        when /text\\/plain/\n          part.body = parse(part.body.decoded.dup, :text)\n          part.content_transfer_encoding = nil\n          part.charset = \"UTF-8\"\n        when /multipart\\/(alternative|related)/\n          unless part.parts.empty?\n            parse_parts(part.parts)\n          end\n        end\n      end\n    end\n\n    def parse(part, type = nil)\n      if @domain.track_clicks?\n        part = insert_links(part, type)\n      end\n\n      if @domain.track_loads? && type == :html\n        part = insert_tracking_image(part)\n      end\n\n      part\n    end\n\n    def insert_links(part, type = nil)\n      if type == :text\n        part.gsub!(/(#{URL_REGEX})(?=\\s|$)/) do\n          if track_domain?($~[:domain])\n            @tracked_links += 1\n            url = $~[:url]\n            while url =~ /[^\\w]$/\n              theend = url.size - 2\n              url = url[0..theend]\n            end\n            token = @message.create_link(url)\n            \"#{domain}/#{@message.server.token}/#{token}\"\n          else\n            ::Regexp.last_match(0)\n          end\n        end\n      end\n\n      if type == :html\n        part.gsub!(/href=(['\"])(#{URL_REGEX})['\"]/) do\n          if track_domain?($~[:domain])\n            @tracked_links += 1\n            url = CGI.unescapeHTML($~[:url])\n            token = @message.create_link(url)\n            \"href='#{domain}/#{@message.server.token}/#{token}'\"\n          else\n            ::Regexp.last_match(0)\n          end\n        end\n      end\n\n      part.gsub!(/(https?)\\+notrack:\\/\\//) do\n        @actioned = true\n        \"#{::Regexp.last_match(1)}://\"\n      end\n\n      part\n    end\n\n    def insert_tracking_image(part)\n      @tracked_images += 1\n      container = \"<p class='ampimg' style='display:none;visibility:none;margin:0;padding:0;line-height:0;'><img src='#{domain}/img/#{@message.server.token}/#{@message.token}' alt=''></p>\"\n      if part =~ /<\\/body>/\n        part.gsub(\"</body>\", \"#{container}</body>\")\n      else\n        part + container\n      end\n    end\n\n    def domain\n      \"#{@domain.use_ssl? ? 'https' : 'http'}://#{@domain.full_name}\"\n    end\n\n    def track_domain?(domain)\n      !@domain.excluded_click_domains_array.include?(domain)\n    end\n\n  end\nend\n"
  },
  {
    "path": "lib/postal/signer.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"base64\"\nmodule Postal\n  class Signer\n\n    # Create a new Signer\n    #\n    # @param [OpenSSL::PKey::RSA] private_key The private key to use for signing\n    # @return [Signer]\n    def initialize(private_key)\n      @private_key = private_key\n    end\n\n    # Return the private key\n    #\n    # @return [OpenSSL::PKey::RSA]\n    attr_reader :private_key\n\n    # Return the public key for the private key\n    #\n    # @return [OpenSSL::PKey::RSA]\n    def public_key\n      @private_key.public_key\n    end\n\n    # Sign the given data\n    #\n    # @param [String] data The data to sign\n    # @return [String] The signature\n    def sign(data)\n      private_key.sign(OpenSSL::Digest.new(\"SHA256\"), data)\n    end\n\n    # Sign the given data and return a Base64-encoded signature\n    #\n    # @param [String] data The data to sign\n    # @return [String] The Base64-encoded signature\n    def sign64(data)\n      Base64.strict_encode64(sign(data))\n    end\n\n    # Return a JWK for the private key\n    #\n    # @return [JWT::JWK] The JWK\n    def jwk\n      @jwk ||= JWT::JWK.new(private_key, { use: \"sig\", alg: \"RS256\" })\n    end\n\n    # Sign the given data using SHA1 (for legacy use)\n    #\n    # @param [String] data The data to sign\n    # @return [String] The signature\n    def sha1_sign(data)\n      private_key.sign(OpenSSL::Digest.new(\"SHA1\"), data)\n    end\n\n    # Sign the given data using SHA1 (for legacy use) and return a Base64-encoded string\n    #\n    # @param [String] data The data to sign\n    # @return [String] The signature\n    def sha1_sign64(data)\n      Base64.strict_encode64(sha1_sign(data))\n    end\n\n  end\nend\n"
  },
  {
    "path": "lib/postal/spam_check.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\n  class SpamCheck\n\n    attr_reader :code, :score, :description\n\n    def initialize(code, score, description = nil)\n      @code = code\n      @score = score\n      @description = description\n    end\n\n    def to_hash\n      {\n        code: code,\n        score: score,\n        description: description\n      }\n    end\n\n  end\nend\n"
  },
  {
    "path": "lib/postal/yaml_config_exporter.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"konfig/exporters/abstract\"\n\nmodule Postal\n  class YamlConfigExporter < Konfig::Exporters::Abstract\n\n    def export\n      contents = []\n      contents << \"version: 2\"\n      contents << \"\"\n\n      @schema.groups.each do |group_name, group|\n        contents << \"#{group_name}:\"\n        group.attributes.each do |name, attr|\n          contents << \"  # #{attr.description}\"\n          if attr.array?\n            if attr.default.blank?\n              contents << \"  #{name}: []\"\n            else\n              contents << \"  #{name}:\"\n              attr.transform(attr.default).each do |d|\n                contents << \"    - #{d}\"\n              end\n            end\n          else\n            contents << \"  #{name}: #{attr.default}\"\n          end\n        end\n        contents << \"\"\n      end\n\n      contents.join(\"\\n\")\n    end\n\n  end\nend\n"
  },
  {
    "path": "lib/postal.rb",
    "content": "# frozen_string_literal: true\n\nmodule Postal\nend\n"
  },
  {
    "path": "lib/tasks/.keep",
    "content": ""
  },
  {
    "path": "lib/tasks/auto_annotate_models.rake",
    "content": "# NOTE: only doing this in development as some production environments (Heroku)\n# NOTE: are sensitive to local FS writes, and besides -- it's just not proper\n# NOTE: to have a dev-mode tool do its thing in production.\nif Rails.env.development?\n  require 'annotate'\n  task :set_annotation_options do\n    # You can override any of these by setting an environment variable of the\n    # same name.\n    Annotate.set_defaults(\n      'active_admin'                => 'false',\n      'additional_file_patterns'    => [],\n      'routes'                      => 'false',\n      'models'                      => 'true',\n      'position_in_routes'          => 'before',\n      'position_in_class'           => 'before',\n      'position_in_test'            => 'before',\n      'position_in_fixture'         => 'before',\n      'position_in_factory'         => 'before',\n      'position_in_serializer'      => 'before',\n      'show_foreign_keys'           => 'true',\n      'show_complete_foreign_keys'  => 'false',\n      'show_indexes'                => 'true',\n      'simple_indexes'              => 'false',\n      'model_dir'                   => 'app/models',\n      'root_dir'                    => '',\n      'include_version'             => 'false',\n      'require'                     => '',\n      'exclude_tests'               => 'false',\n      'exclude_fixtures'            => 'false',\n      'exclude_factories'           => 'false',\n      'exclude_serializers'         => 'false',\n      'exclude_scaffolds'           => 'true',\n      'exclude_controllers'         => 'true',\n      'exclude_helpers'             => 'true',\n      'exclude_sti_subclasses'      => 'false',\n      'ignore_model_sub_dir'        => 'false',\n      'ignore_columns'              => nil,\n      'ignore_routes'               => nil,\n      'ignore_unknown_models'       => 'false',\n      'hide_limit_column_types'     => 'integer,bigint,boolean',\n      'hide_default_column_types'   => 'json,jsonb,hstore',\n      'skip_on_db_migrate'          => 'false',\n      'format_bare'                 => 'true',\n      'format_rdoc'                 => 'false',\n      'format_yard'                 => 'false',\n      'format_markdown'             => 'false',\n      'sort'                        => 'false',\n      'force'                       => 'false',\n      'frozen'                      => 'false',\n      'classified_sort'             => 'true',\n      'trace'                       => 'false',\n      'wrapper_open'                => nil,\n      'wrapper_close'               => nil,\n      'with_comment'                => 'true'\n    )\n  end\n\n  Annotate.load_tasks\nend\n"
  },
  {
    "path": "lib/tasks/postal.rake",
    "content": "# frozen_string_literal: true\n\nnamespace :postal do\n  desc \"Run all migrations on message databases\"\n  task migrate_message_databases: :environment do\n    Server.all.each do |server|\n      puts \"Running migrations for #{server.organization.permalink}/#{server.permalink} (ID: #{server.id})\"\n      server.message_db.provisioner.migrate\n    end\n  end\n\n  desc \"Generate configuration documentation\"\n  task generate_config_docs: :environment do\n    require \"konfig/exporters/env_vars_as_markdown\"\n\n    FileUtils.mkdir_p(\"doc/config\")\n    output = Konfig::Exporters::EnvVarsAsMarkdown.new(Postal::ConfigSchema).export\n    File.write(\"doc/config/environment-variables.md\", output)\n\n    output = Postal::YamlConfigExporter.new(Postal::ConfigSchema).export\n    File.write(\"doc/config/yaml.yml\", output)\n  end\n\n  desc \"Generate Helm Environment Variables\"\n  task generate_helm_env_vars: :environment do\n    puts Postal::HelmConfigExporter.new(Postal::ConfigSchema).export\n  end\n\n  desc \"Update the database\"\n  task update: :environment do\n    mysql = ActiveRecord::Base.connection\n    if mysql.table_exists?(\"schema_migrations\") &&\n       mysql.select_all(\"select * from schema_migrations\").any?\n      puts \"Database schema is already loaded. Running migrations with db:migrate\"\n      Rake::Task[\"db:migrate\"].invoke\n    else\n      puts \"No schema migrations exist. Loading schema with db:schema:load\"\n      Rake::Task[\"db:schema:load\"].invoke\n    end\n  end\nend\n\nRake::Task[\"db:migrate\"].enhance do\n  Rake::Task[\"postal:migrate_message_databases\"].invoke\nend\n"
  },
  {
    "path": "lib/tracking_middleware.rb",
    "content": "# frozen_string_literal: true\n\nclass TrackingMiddleware\n\n  TRACKING_PIXEL = File.read(Rails.root.join(\"app\", \"assets\", \"images\", \"tracking_pixel.png\"))\n\n  def initialize(app = nil)\n    @app = app\n  end\n\n  def call(env)\n    unless env[\"HTTP_X_POSTAL_TRACK_HOST\"].to_i == 1\n      return @app.call(env)\n    end\n\n    request = Rack::Request.new(env)\n\n    case request.path\n    when /\\A\\/img\\/([a-z0-9-]+)\\/([a-z0-9-]+)/i\n      server_token = ::Regexp.last_match(1)\n      message_token = ::Regexp.last_match(2)\n      dispatch_image_request(request, server_token, message_token)\n    when /\\A\\/([a-z0-9-]+)\\/([a-z0-9-]+)/i\n      server_token = ::Regexp.last_match(1)\n      link_token = ::Regexp.last_match(2)\n      dispatch_redirect_request(request, server_token, link_token)\n    else\n      [200, {}, [\"Hello.\"]]\n    end\n  end\n\n  private\n\n  def dispatch_image_request(request, server_token, message_token)\n    message_db = get_message_db_from_server_token(server_token)\n    if message_db.nil?\n      return [404, {}, [\"Invalid Server Token\"]]\n    end\n\n    begin\n      message = message_db.message(token: message_token)\n      message.create_load(request)\n    rescue Postal::MessageDB::Message::NotFound\n      # This message has been removed, we'll just continue to serve the image\n    rescue StandardError => e\n      # Somethign else went wrong. We don't want to stop the image loading though because\n      # this is our problem. Log this exception though.\n      Sentry.capture_exception(e) if defined?(Sentry)\n    end\n\n    source_image = request.params[\"src\"]\n    case source_image\n    when nil\n      headers = {}\n      headers[\"Content-Type\"] = \"image/png\"\n      headers[\"Content-Length\"] = TRACKING_PIXEL.bytesize.to_s\n      [200, headers, [TRACKING_PIXEL]]\n    when /\\Ahttps?:\\/\\//\n      response = Postal::HTTP.get(source_image, timeout: 3)\n      return [404, {}, [\"Not found\"]] unless response[:code] == 200\n\n      headers = {}\n      headers[\"Content-Type\"] = response[:headers][\"content-type\"]&.first\n      headers[\"Last-Modified\"] = response[:headers][\"last-modified\"]&.first\n      headers[\"Cache-Control\"] = response[:headers][\"cache-control\"]&.first\n      headers[\"Etag\"] = response[:headers][\"etag\"]&.first\n      headers[\"Content-Length\"] = response[:body].bytesize.to_s\n      [200, headers, [response[:body]]]\n\n    else\n      [400, {}, [\"Invalid/missing source image\"]]\n    end\n  end\n\n  def dispatch_redirect_request(request, server_token, link_token)\n    message_db = get_message_db_from_server_token(server_token)\n    if message_db.nil?\n      return [404, {}, [\"Invalid Server Token\"]]\n    end\n\n    link = message_db.select(:links, where: { token: link_token }, limit: 1).first\n    if link.nil?\n      return [404, {}, [\"Link not found\"]]\n    end\n\n    time = Time.now.to_f\n    if link[\"message_id\"]\n      message_db.update(:messages, { clicked: time }, where: { id: link[\"message_id\"] })\n      message_db.insert(:clicks, {\n        message_id: link[\"message_id\"],\n        link_id: link[\"id\"],\n        ip_address: request.ip,\n        user_agent: request.user_agent,\n        timestamp: time\n      })\n\n      begin\n        message_webhook_hash = message_db.message(link[\"message_id\"]).webhook_hash\n        WebhookRequest.trigger(message_db.server, \"MessageLinkClicked\", {\n          message: message_webhook_hash,\n          url: link[\"url\"],\n          token: link[\"token\"],\n          ip_address: request.ip,\n          user_agent: request.user_agent\n        })\n      rescue Postal::MessageDB::Message::NotFound\n        # If we can't find the message that this link is associated with, we'll just ignore it\n        # and not trigger any webhooks.\n      end\n    end\n\n    [307, { \"Location\" => link[\"url\"] }, [\"Redirected to: #{link['url']}\"]]\n  end\n\n  def get_message_db_from_server_token(token)\n    return unless server = ::Server.find_by_token(token)\n\n    server.message_db\n  end\n\nend\n"
  },
  {
    "path": "log/.keep",
    "content": ""
  },
  {
    "path": "public/404.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <title>The page you were looking for doesn't exist (404)</title>\n  <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n  <style>\n  body {\n    background-color: #EFEFEF;\n    color: #2E2F30;\n    text-align: center;\n    font-family: arial, sans-serif;\n    margin: 0;\n  }\n\n  div.dialog {\n    width: 95%;\n    max-width: 33em;\n    margin: 4em auto 0;\n  }\n\n  div.dialog > div {\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #BBB;\n    border-top: #B00100 solid 4px;\n    border-top-left-radius: 9px;\n    border-top-right-radius: 9px;\n    background-color: white;\n    padding: 7px 12% 0;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n\n  h1 {\n    font-size: 100%;\n    color: #730E15;\n    line-height: 1.5em;\n  }\n\n  div.dialog > p {\n    margin: 0 0 1em;\n    padding: 1em;\n    background-color: #F7F7F7;\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #999;\n    border-bottom-left-radius: 4px;\n    border-bottom-right-radius: 4px;\n    border-top-color: #DADADA;\n    color: #666;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n  </style>\n</head>\n\n<body>\n  <!-- This file lives in public/404.html -->\n  <div class=\"dialog\">\n    <div>\n      <h1>The page you were looking for doesn't exist.</h1>\n      <p>You may have mistyped the address or the page may have moved.</p>\n    </div>\n    <p>If you are the application owner check the logs for more information.</p>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "public/422.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <title>The change you wanted was rejected (422)</title>\n  <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n  <style>\n  body {\n    background-color: #EFEFEF;\n    color: #2E2F30;\n    text-align: center;\n    font-family: arial, sans-serif;\n    margin: 0;\n  }\n\n  div.dialog {\n    width: 95%;\n    max-width: 33em;\n    margin: 4em auto 0;\n  }\n\n  div.dialog > div {\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #BBB;\n    border-top: #B00100 solid 4px;\n    border-top-left-radius: 9px;\n    border-top-right-radius: 9px;\n    background-color: white;\n    padding: 7px 12% 0;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n\n  h1 {\n    font-size: 100%;\n    color: #730E15;\n    line-height: 1.5em;\n  }\n\n  div.dialog > p {\n    margin: 0 0 1em;\n    padding: 1em;\n    background-color: #F7F7F7;\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #999;\n    border-bottom-left-radius: 4px;\n    border-bottom-right-radius: 4px;\n    border-top-color: #DADADA;\n    color: #666;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n  </style>\n</head>\n\n<body>\n  <!-- This file lives in public/422.html -->\n  <div class=\"dialog\">\n    <div>\n      <h1>The change you wanted was rejected.</h1>\n      <p>Maybe you tried to change something you didn't have access to.</p>\n    </div>\n    <p>If you are the application owner check the logs for more information.</p>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "public/500.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <title>We're sorry, but something went wrong (500)</title>\n  <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n  <style>\n  body {\n    background-color: #EFEFEF;\n    color: #2E2F30;\n    text-align: center;\n    font-family: arial, sans-serif;\n    margin: 0;\n  }\n\n  div.dialog {\n    width: 95%;\n    max-width: 33em;\n    margin: 4em auto 0;\n  }\n\n  div.dialog > div {\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #BBB;\n    border-top: #B00100 solid 4px;\n    border-top-left-radius: 9px;\n    border-top-right-radius: 9px;\n    background-color: white;\n    padding: 7px 12% 0;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n\n  h1 {\n    font-size: 100%;\n    color: #730E15;\n    line-height: 1.5em;\n  }\n\n  div.dialog > p {\n    margin: 0 0 1em;\n    padding: 1em;\n    background-color: #F7F7F7;\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #999;\n    border-bottom-left-radius: 4px;\n    border-bottom-right-radius: 4px;\n    border-top-color: #DADADA;\n    color: #666;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n  </style>\n</head>\n\n<body>\n  <!-- This file lives in public/500.html -->\n  <div class=\"dialog\">\n    <div>\n      <h1>We're sorry, but something went wrong.</h1>\n    </div>\n    <p>If you are the application owner check the logs for more information.</p>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "public/robots.txt",
    "content": "# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file\n#\n# To ban all spiders from the entire site uncomment the next two lines:\n# User-agent: *\n# Disallow: /\n"
  },
  {
    "path": "release-please-config.json",
    "content": "{\n  \"bootstrap-sha\": \"76f43140ae57964c871bb654d70d88ecc2210cb1\",\n  \"packages\": {\n    \".\": {\n      \"release-type\": \"ruby\",\n      \"changelog-path\": \"CHANGELOG.md\",\n      \"bump-minor-pre-major\": true,\n      \"bump-patch-for-minor-pre-major\": true,\n      \"draft\": false,\n      \"prerelease\": false,\n      \"include-v-in-tag\": false,\n      \"changelog-sections\": [\n        {\n          \"type\": \"feat\",\n          \"section\": \"Features\"\n        },\n        {\n          \"type\": \"feature\",\n          \"section\": \"Features\"\n        },\n        {\n          \"type\": \"fix\",\n          \"section\": \"Bug Fixes\"\n        },\n        {\n          \"type\": \"perf\",\n          \"section\": \"Performance Improvements\"\n        },\n        {\n          \"type\": \"revert\",\n          \"section\": \"Reverts\"\n        },\n        {\n          \"type\": \"docs\",\n          \"section\": \"Documentation\"\n        },\n        {\n          \"type\": \"style\",\n          \"section\": \"Styles\"\n        },\n        {\n          \"type\": \"chore\",\n          \"section\": \"Miscellaneous Chores\"\n        },\n        {\n          \"type\": \"refactor\",\n          \"section\": \"Code Refactoring\"\n        },\n        {\n          \"type\": \"test\",\n          \"section\": \"Tests\"\n        },\n        {\n          \"type\": \"build\",\n          \"section\": \"Build System\"\n        },\n        {\n          \"type\": \"ci\",\n          \"section\": \"Continuous Integration\"\n        }\n      ]\n    }\n  },\n  \"$schema\": \"https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json\"\n}\n"
  },
  {
    "path": "resource/postfix-bounce.msg",
    "content": "Received: from VI1PR0501MB2190.eurprd05.prod.outlook.com (10.169.134.137) by\n VI1PR0501MB2191.eurprd05.prod.outlook.com (10.169.134.138) with Microsoft\n SMTP Server (version=TLS1_2,\n cipher=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384_P384) id 15.1.649.16 via Mailbox\n Transport; Fri, 14 Oct 2016 16:35:20 +0000\nReceived: from DB3PR05CA0081.eurprd05.prod.outlook.com (10.163.44.49) by\n VI1PR0501MB2190.eurprd05.prod.outlook.com (10.169.134.137) with Microsoft\n SMTP Server (version=TLS1_2,\n cipher=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384_P384) id 15.1.669.12; Fri, 14\n Oct 2016 16:35:18 +0000\nReceived: from HE1EUR02FT026.eop-EUR02.prod.protection.outlook.com\n (2a01:111:f400:7e05::204) by DB3PR05CA0081.outlook.office365.com\n (2a01:111:e400:9448::49) with Microsoft SMTP Server (version=TLS1_2,\n cipher=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384_P384) id 15.1.669.12 via\n Frontend Transport; Fri, 14 Oct 2016 16:35:18 +0000\nAuthentication-Results: spf=none (sender IP is 185.22.208.4)\n smtp.helo=smtp.infra.atech.io; atech.media; dkim=none (message not signed)\n header.d=none;atech.media; dmarc=none action=none\n header.from=smtp.infra.atech.io;atech.media; dkim=none (message not signed)\n header.d=none;\nReceived-SPF: None (protection.outlook.com: smtp.infra.atech.io does not\n designate permitted sender hosts)\nReceived: from smtp.infra.atech.io (185.22.208.4) by\n HE1EUR02FT026.mail.protection.outlook.com (10.152.10.130) with Microsoft SMTP\n Server id 15.1.669.7 via Frontend Transport; Fri, 14 Oct 2016 16:35:17 +0000\nReceived: by smtp.infra.atech.io (Postfix)\n  id 5E83481831; Fri, 14 Oct 2016 17:35:17 +0100 (BST)\nDate: Fri, 14 Oct 2016 17:35:17 +0100\nFrom: Mail Delivery System <MAILER-DAEMON@smtp.infra.atech.io>\nSubject: Undelivered Mail Returned to Sender\nTo: <adam@atech.media>\nAuto-Submitted: auto-replied\nMIME-Version: 1.0\n  boundary=\"CE47D81645.1476462917/smtp.infra.atech.io\"\nMessage-ID: <20161014163517.5E83481831@smtp.infra.atech.io>\nReturn-Path: <>\nX-MS-Exchange-Organization-Network-Message-Id: 8e492c55-8762-40ec-6834-08d3f4501539\nX-EOPAttributedMessage: 0\nX-EOPTenantAttributedMessage: 7a8f6edf-720f-4e3d-b767-1360e39a8cdf:0\nX-MS-Exchange-Organization-MessageDirectionality: Incoming\nX-Forefront-Antispam-Report: CIP:185.22.208.4;IPV:NLI;CTRY:EU;EFV:NLI;SFV:NSPM;SFS:(6009001)(8076002)(2970300002)(428002)(199003)(189002)(1930700014)(319900001)(107886002)(42186005)(512954002)(70486001)(105586002)(8676002)(189998001)(2351001)(229853001)(1476001)(5260600002)(50986999)(54356999)(200953004)(19580405001)(980100002)(110136003)(260700001)(19580395003)(33656002)(84326002)(236004)(104016004)(1096003)(78352004)(8896002)(101416001)(90896003)(118176002)(1076002)(5660300001)(6916009)(7636002)(305945005)(7596002)(246002)(5890100001)(11100500001)(109986004)(106466001)(92566002)(8266002)(626004)(586003)(74482002)(7846002)(356003)(42882006)(32350400003);DIR:INB;SFP:;SCL:1;SRVR:VI1PR0501MB2190;H:smtp.infra.atech.io;FPR:;SPF:None;PTR:smtp.infra.atech.io;A:0;MX:0;LANG:en;\nX-Microsoft-Exchange-Diagnostics: 1;HE1EUR02FT026;1:I844bOJngljCMA66kUYoR2G6kRONLMg+LRS2c4Xfia4N/WXH5mUvjHtxjXyjqb+VtC1HwQAe4ibwrT5WyE063lXQo2HzkP7h+zPxKCFdPQyTIe+h8tDadrqSbipyPWqk8V/i6ncNTRNHQCe94tJZ/Y/X45XAy9PSyTVaf3LCj9a0RRQVJl6fs2RDgS7UQnyH9aomOtKh/G/5tZFQ9CDh6z4TGZcs5ZRyJRvDs9yJTW7YqkWMFh2/1C3DVmfm9HaTaXfR9BldNdjPiYVCf/tIfIUuH1l0ntJ3hYh4XDUOoW0Otaaa/gt3EKlnF+3xnQwuz/EMy26jW1/vRyp/bNWDzZylZQSKrPboEdCwrJABMQAY+AH7bhw3unBsiTk43+E4OIAttZFdFjIUSs2EXNUivg1P+QpHaS1w7ISqqKviDISD4LRnOZcRhw3AHrk6ntcSi180yb+GEPATi4JWRSKuTTcdNHJPxEForqsf1Ox1EZBPIigucsUjtv3UBt1oYMLIhbXEewmotwL1VcbFQ0YlC11gg58XhU+V3khFlLJyiKzRgqHW6uZeMCHaxtVa0Roy\nX-MS-Office365-Filtering-Correlation-Id: 8e492c55-8762-40ec-6834-08d3f4501539\nX-Microsoft-Exchange-Diagnostics: 1;VI1PR0501MB2190;2:aUTFT3w3QZZEV8+lrRCdxdfnYgzqlR4Xn2axr8MMp5nLitASDx7U281FjCS9/QxrU4CpLaKeUfCF0FwfW806abdZjnFEq64HQRxvooQYtjvXRSI2AoyPXiIo7wqHUDqiQXODzzMdM7iw0EYJfSAQ2TiB//WEYfKoa4AEFXA/vklWCRgUa5vX2ssBnHZIS+r32ZBRCmuVkuCpn5SGjYWZcA==;3:/+ND5URiD7tXAGq+5t+TGeEbqtWdbGKaffRKGPX5tYRL/I+cqFHKRNZEJ30Fir7qb1B9wlPdhJLSnznZE00PWeaxdXTvgQ2iR+vTkRvm1SKihpilX8EfZuV3oxZTRte5G8U59jp+LEF0Btm/rvzJLNWfdv1HtaR1QK2NwxzaE61s29j9fAk6rO2uv0zvp08BO8RGbN+8vftyxJ3o8C/Ffk3Xu9WGHMj30oiY7zvo9dWkUoKWsonYHoBouBEpmr2Rz0qwcKMBY1Qvx4Tl57BpAnr7KG83hZnnm22YkO+mWdw=\nX-Microsoft-Antispam: UriScan:;BCL:0;PCL:0;RULEID:(71701004)(71702002);SRVR:VI1PR0501MB2190;\nX-Microsoft-Exchange-Diagnostics: =?us-ascii?Q?1;VI1PR0501MB2190;25:VqLlqJIUDn9lYBg6DM4R4YDyfKs58O/fJx/WdJ+?=\n =?us-ascii?Q?s/if72bbElV0vSsZFmeL/SxqmGCsoE8sRj39NXj1LR9dxnsujR2lW42/+ZHq?=\n =?us-ascii?Q?NIknGBkbrAL0P7QSSnB8QjVNAvcSnf11ZoO6pjw+1EGw7sJ+UCw3q1PeeP2B?=\n =?us-ascii?Q?9ov8qltugc+iftqBVBTrKgtTpMlM5BROii684na1dJwOBp1xlE+1NVKfRRJO?=\n =?us-ascii?Q?os/NdHlpN+q0iac1ZxfF9Hy3YcPoPB++gUbG7ufmH4GF6yyFH9jvdm4uDXwl?=\n =?us-ascii?Q?eUJJRNwMvKuE/syPuhjVZrGDLQSpcFKP5SaFUrGyTNYIeCEwHvx1CpEwU/+C?=\n =?us-ascii?Q?u4uOlIcFO7JrPHTQLUs2+nPxrQvwyNuhTo45+OMf3siAVbYK0wV1qvFTIn0f?=\n =?us-ascii?Q?3SoWd+it04MucWq1864dGjpbECpB+zAU7KPznU5R4xbeyR1Dfb908Lzxso0W?=\n =?us-ascii?Q?/4BhR1F/YINyRSB4vtochuRe7TW2+4cTiIom1Kc6peQIk2vp9U3HdGXvODSk?=\n =?us-ascii?Q?3ZeaLuc9mbn6lWDKhUk9w4s86WsfvQxHTiCxmR4IHmKd6Ja6KQ/hEcM8McSi?=\n =?us-ascii?Q?An+Ot9WGcAmgSBVU0uUPVG9i+J1dWvd+EKXqi6+tYpyCp8Q7F8WtYbvGv3rg?=\n =?us-ascii?Q?8BM11cgvhBhVt63PztuiOl+S+Upv88AdS7Mm2sSCvyHqjLECBKhsPo1cIXXg?=\n =?us-ascii?Q?jaES2NWR6aRlHnUU/yEMLmOnG6B5iwumfv3ZCzsjw/GNAe1f7OFlGWkQjC62?=\n =?us-ascii?Q?GEwG3Kki5zGjVDC5ojpXOB6uElWHzl4/+BemmFrsokNfYVjbl9e1kWle6GVM?=\n =?us-ascii?Q?N13V/z+J3y1DiyontAIS0UVQfC3FnJyJMmOnXMxLTPmIhBWh5+9nCBCoY6MQ?=\n =?us-ascii?Q?1xOKYiEuGnix3DwHBzaIIFpOoASZdraqXkp2vA+J3f3K1fDma+4mcR2fWg/C?=\n =?us-ascii?Q?YQ1CFAUkJavxaSBrtKGY6/1996Py7ptB0PBYQZlvbPg=3D=3D?=\nX-MS-Exchange-Organization-AVStamp-Service: 1.0\nX-Microsoft-Exchange-Diagnostics: 1;VI1PR0501MB2190;31:b0MKU8zAViS2/LC7i8kN/a8eET6sf1RqcUrU4gG5Giu0wUTaiE4V9UKOxlXScHoi6sh+Rj2hw06hbXrNb5XwSc88MlVF/fCSwIcWp83MxULI/RDJD8iOv+1jh5pIFJ/vdD/z49bAYADk0QlOQv/1Pk12MQwrJ+8WhSvYd4RMMNq6TWKHDm9wKRDK+5dGjOxM4SnG2xhpjzi1c8yS+HpY2w1zNZ686LOh6n51mzbrBmD04obt4jelXejWUcMgflUFV4TXigkcfJyy67fsAOFKSM3Z7HDWlj2MsVWr3LJoZDI=;20:OFobvPPLLQUKE90FHL0X31lqnxiVftfAQcnzPgyEb/Fr9l2vaWrvfMtT/Ug6x+EcY/SeMOGtgU4/1DmaDDp/myCrXmkKXDBhAZ89ynLFLwcWtWgM2kTurdpLATc2UzGJUzK4waroTJ7rWziAgvvsJzopsoZ7bJo27H+uWiImrealhyBjGnkZjyPbR22SLTKthjFM+E1GpQsY3A9YAgu+ClPJN+fhdgYvMqJx4UvnmyTcBiYiQHW9DeXA5kiBkk6k\nX-Exchange-Antispam-Report-Test: UriScan:;\nX-Exchange-Antispam-Report-CFA-Test: BCL:0;PCL:0;RULEID:(102415321)(9101531078)(601004)(7630418)(2401047)(8121501046)(13016025)(5008015)(13018025)(7631346)(7632255)(7633261)(9101536074)(3002001)(10201501046);SRVR:VI1PR0501MB2190;BCL:0;PCL:0;RULEID:;SRVR:VI1PR0501MB2190;\nX-Microsoft-Exchange-Diagnostics: 1;VI1PR0501MB2190;4:sTHjSfxBSWZeJ0S7hRyvosCSGoRNY8//2+7naQ3ET32xC5g1HyMDqJ+DL0cgr418hPz4P3k6LKooUUqZD1Mbxip6E9lRjsh4kO4PrKY80XKF1UySxqNnLLrPz24elOdyEh88MJfh0+iV4wLcKxXOY5MMx94E1+Fs4uQNF3ujfxl9lJ4qV3fTnbpETTHCZJG98jXmFBgCGElpyDUo7X9ly0X5VpkS4e3N8qT5DHSlLEygRp+j1DgmmwuSIuzLB55F0W1LBdzr36m2oEopFT3bKUk7Q8fQsNPQRORxh6zW1AfAcN4tI4+e2t3Q0ZIIxGbH21GnFgXTavq1TCIbwHF6xemlK7OxMRIM2L3LT9qZKD7gM2Ymn9bE5XWP1Rg8kXBwf5Ae8rGAsfUWu+in5XD1sFBhCdLWu64sqSCo4KJtqg5ysvhQ3omeu3v11W4DTdEEq2c2Ec8YRLQ+SleeV7Ge7dm48RhYunXMuzpQK+ZMyaGP1oGBPfudZmFTpl3G0rJ8PQjzC2WFHFHR75RN2TyqHVuxq5JHqtQZKZ+cj3Ngfa6Hafr6vJcBIdumuy/Hfhut\nX-MS-Exchange-Organization-SCL: 1\nX-Microsoft-Exchange-Diagnostics: =?us-ascii?Q?1;VI1PR0501MB2190;23:yTE63xp8HYBizj51N5rtQxMlXUnOkjNXLDQbVii?=\n =?us-ascii?Q?74MutjfsHpQNE0ZtjT8XUVfxJhkOnhKaULJa17nAn33mtM9R0FwXUejtpsM3?=\n =?us-ascii?Q?C8+0Em8ArbXLLw71IhWwwqNZWDWmJisoLHADJa4nKx1CrfW6484sj+L+gcUs?=\n =?us-ascii?Q?7jjzgwo7DiFHIGg3PWV+HTSlzwzIo7owquBwo+39AX6VwN9BSNpCLBIJXFv4?=\n =?us-ascii?Q?iSmfRiXce9LzweVccWXjCQHTDz3JLeAo77jOJfQdw413irWDqVe8J8M1a1EC?=\n =?us-ascii?Q?YX72/Nkn8rGrXySPRyBUVsMB17wQZeDiEgsfIGXccMRwmcw+tjnkhYOzVE5X?=\n =?us-ascii?Q?fT0jgcJilO4ZmP1qxhOZVFIjNVfAPlhuBVy58BjcA8oXS2qKiGX3dZ6T9XHm?=\n =?us-ascii?Q?A6VtWsuOLHOdXJ0OEwI+BKqOWoMV4nIeafvW88umhRvbkdAtbwg/rMWU9VA6?=\n =?us-ascii?Q?dHR+NarhqQHoRkOlhV43YcwpRKLuDxNPw1y09Ew0YhVLaAaTA/SJeDkvroh9?=\n =?us-ascii?Q?EMaS+56FgltfDBErOZUGvHzW2ln9lyvx5UtTB1QQMMYLMBT5m94671awiLCY?=\n =?us-ascii?Q?5es9uPachr7pdh9+nP2rXp0XKayN6/Hq+U1qVGAhGdgawDN0n+Htk66F+vl9?=\n =?us-ascii?Q?mlTtADgbn8DROvBLgxLeMYrDrQiVuHLQlFS3ztve4Kb7U6UXI0JTvVPTEgk7?=\n =?us-ascii?Q?0z41bT/lZ/AxkhUrrNOZ+687OQGLNnYhrKNsklBYypk5VGJd5hKwhYyKGC/x?=\n =?us-ascii?Q?5Hpu3ncW9o0/XUKH410/cZtVdLetnTi1khQeQjL93CcOGBDVQancgeDIlore?=\n =?us-ascii?Q?nKKQXK2mKr1E7fI8PUOhvFFCr+EIe71Q7Vx7UioZHAZm0bSNBtBxnktFxmvE?=\n =?us-ascii?Q?fOtucak359AqrppMbY1INtv6aaLGT6p1/4jjXTBIwzmtjVXU3krNbFe0/NBo?=\n =?us-ascii?Q?f8rjDnTrRD8yXHuzJUHnKhKDwh1E30Vnrb0TZ0/75OiBpoRjoznJpxwjJvhD?=\n =?us-ascii?Q?IB6WJ723bwBl1ICZdBFx+j6y/TdihKwiCTpigKh78jwMTt3WglNAyfMvgS4w?=\n =?us-ascii?Q?iyYopxubRAuYGKGMjZ2v7qF1ApCYrTWc8vdlD65PPYzsbUpUcQiwIURYe6Rm?=\n =?us-ascii?Q?qF+/MmYDVS381jTfqOrOM2XmxKmg5vMe7iyj9/y9eqwLmPXVs0h5uU+MVmBA?=\n =?us-ascii?Q?kxJJMWPurFX3urVXrIexkB1zhZl1aKYT7LAUX3PKeeIhd+WoGCnQJhxedP1W?=\n =?us-ascii?Q?H7sanIn2ceGPPMLyit54gZyyHNNOZWvJ8QsqntSBD9m62ltTJuaWnvMfgDbr?=\n =?us-ascii?Q?1CuLVJpr7Dxf4J6e6Dt1NBf4MmBegakrjofmt2TYlgzYm7XXkYr+4ghew5I9?=\n =?us-ascii?Q?dC+tbXjDbCFvQQjBmbKH/wsssBaU=3D?=\nX-Microsoft-Exchange-Diagnostics: 1;VI1PR0501MB2190;6:JwlMePjcnUqmqozAbSNa4hS+ETMtos5zDZcAl4XWQnFNRRdJ0Y5OT7bRk6S0lu1r3+aN//scqEWnoh8Dz3gbuqjOnMIueBsnd7EUJivfeq+UlSVj1cDGWg3jIHuOf9IwX5pK0A7VTLz6nbHuJhRnH5xkFPDewGW8Ge7lYgsrP25GKvqfkPwd8tNB4zanq1P6wET7NjTS1IRY2yDhg2himp6W+QIsCraBzvlNK5Gc7x63EM0hl29ThgZj9z9fvP6Ba3kHrQVLX2WoYXSHMlhDmXwaxGRctEJAayybct/OLs4P0uxwEOMLzLDWh8dhXULp/1snUuP6uGYnLyKl+AfmVQ==;5:420rUtpB+7gXb8r10s9tlIeapMEu6GSiDA+fj5XDSDwWgEQqHaYBMWtWr3Rk1tv+hrv6C+SdH2SIAg8u0bXBO6H1SRk/Yp3D9pNs9JBWA6eGIfJYhLdybeCEkt+8AqaPPUU8PxpXnU/0ioHPs7PG6w==;24:Yx2UW5eboTVZTwCGDTUZUWh/I5hto8px21ATw2ZhvDKaAQTdUP/6sbXCfwaKTR9Wd6xU8ikzd5S1A5YEZCZBact32UFs6jAQfiBrVD9I3EU=\nSpamDiagnosticOutput: 1:99\nSpamDiagnosticMetadata: NSPM\nX-Microsoft-Exchange-Diagnostics: 1;VI1PR0501MB2190;7:Jz7xyfTfs161egz9LxVaqUq3431sPAaW6VjWp9Nuyr07EUvmGt9w8MboRMzA0D3SVSPW6t7dWNY9X1xmD298DKxO+tM0WVIaDVLmQdu5G/8ut+j5Dj358ixzLsCNhiGmw5jmpBGOxbX3WAFXEPnHlT6riGn3OwKHXoaGUXkj6ty+9g1tZ5Be5gANT8g3pL0teIn1EnwIxZFaKYrbsU1UTqla1Fs+XYAq/69fGhf8D/qUjJCZwOFz+VApHyX9ZCjjLcbGNGX4BX0Nc/BVDm+JcoJZdJrtacuw5jb4oJ/kXEnVKpT3NGzes0xWG9aW4/5DKm6yn2HeMQzX9NAGq6qeBlnvZ73l/yAMLZTA0Jk4AIhjrJF0pV8h9osIRBZIfJse\nX-MS-Exchange-CrossTenant-OriginalArrivalTime: 14 Oct 2016 16:35:17.6110\n (UTC)\nX-MS-Exchange-CrossTenant-Id: 7a8f6edf-720f-4e3d-b767-1360e39a8cdf\nX-MS-Exchange-CrossTenant-FromEntityHeader: Internet\nX-MS-Exchange-Transport-CrossTenantHeadersStamped: VI1PR0501MB2190\nX-MS-Exchange-Organization-AuthSource: HE1EUR02FT026.eop-EUR02.prod.protection.outlook.com\nX-MS-Exchange-Organization-AuthAs: Anonymous\nX-MS-Exchange-Transport-EndToEndLatency: 00:00:02.4496806\nX-Microsoft-Exchange-Diagnostics:\n  1;VI1PR0501MB2191;9:SJUnxBDmvaYHOl1agY6hYuvGmkli56RSXmbGFtfIu0KRNwHUIt71jXiSsxG10OYqNdlWMJyBY+5ImX4LACrXih6FSeyeZ3EgjDbtzQkj4FCmeU4P6rCS3/6W8VXFWvRzyFTrRIsW9kQbYVo7PGrjMRtBOegIvcfXu1RvXfR5Nw/DKCRKxWUCtGbzOJdrE/C3pTu8BtCe/8juk9VZtaJ+dxRxVylvTNDKviEJ2aGuxl8=\nContent-type: multipart/related;\n  boundary=\"B_3559311380_1176939300\"\n\n> This message is in MIME format. Since your mail reader does not understand\nthis format, some or all of this message may not be legible.\n\n--B_3559311380_1176939300\nContent-type: text/plain;\n  charset=\"UTF-8\"\nContent-transfer-encoding: 7bit\n\nThis is the mail system at host smtp.infra.atech.io.\n\nI'm sorry to have to inform you that your message could not\nbe delivered to one or more recipients. It's attached below.\n\nFor further assistance, please send mail to postmaster.\n\nIf you do so, please include this problem report. You can\ndelete your own text from the attached returned message.\n\n                   The mail system\n\n<bob@nutty.tk>: host mail.nutty.tk[185.22.208.135] said: 550 5.1.1\n    <bob@nutty.tk>: Recipient address rejected: User unknown in virtual mailbox\n    table (in reply to RCPT TO command)\n\n\n--B_3559311380_1176939300\nContent-type: message/rfc822\nContent-disposition: attachment\n\nReturn-Path: <adam@atech.media>\nReceived: from test (unknown [IPv6:2a02:8010:6006:0:60c1:e86c:da5c:9a79])\n  by smtp.infra.atech.io (Postfix) with SMTP id CE47D81645\n  for <bob@nutty.tk>; Fri, 14 Oct 2016 17:25:23 +0100 (BST)\nSubject: testing message\nx-deliver-msgid: {{MSGID}}\nTo: <bob@nutty.tk>\nFrom: <adam@atech.media>\nContent-Type: text/plain\nMIME-Version: 1.0\n\nTesting.\nThis is a bounce.\n\n\n--B_3559311380_1176939300--\n\n"
  },
  {
    "path": "script/default_dkim_record.rb",
    "content": "# frozen_string_literal: true\n\nENV[\"SILENCE_POSTAL_CONFIG_LOCATION_MESSAGE\"] = \"true\"\nrequire File.expand_path(\"../lib/postal/config\", __dir__)\nputs Postal.rp_dkim_dns_record\n"
  },
  {
    "path": "script/generate_tls_certificate.rb",
    "content": "# frozen_string_literal: true\n\nrequire File.expand_path(\"../lib/postal/config\", __dir__)\nrequire \"openssl\"\n\nkey_path = Postal::Config.smtp_server.tls_private_key_path\ncert_path = Postal::Config.smtp_server.tls_certificate_path\n\nunless File.exist?(key_path)\n  key = OpenSSL::PKey::RSA.new(2048).to_s\n  File.write(key_path, key)\n  puts \"Created new private key for encrypting SMTP connections at #{key_path}\"\nend\n\nunless File.exist?(cert_path)\n  cert = OpenSSL::X509::Certificate.new\n  cert.subject = cert.issuer = OpenSSL::X509::Name.parse(\"/C=GB/O=Test/OU=Test/CN=Test\")\n  cert.not_before = Time.now\n  cert.not_after = Time.now + (365 * 24 * 60 * 60)\n  cert.public_key = SMTPServer::Server.tls_private_key.public_key\n  cert.serial = 0x0\n  cert.version = 2\n  cert.sign SMTPServer::Server.tls_private_key, OpenSSL::Digest.new(\"SHA256\")\n  File.write(cert_path, cert.to_pem)\n  puts \"Created new self signed certificate for encrypting SMTP connections at #{cert_path}\"\nend\n"
  },
  {
    "path": "script/insert-bounce.rb",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\n# This script will insert a message into your database that looks like a bounce\n# for a message that you specify.\n\n# usage: insert-bounce.rb [serverid] [messageid]\n\nif ARGV[0].nil? || ARGV[1].nil?\n  puts \"usage: #{__FILE__} [server-id] [message-id]\"\n  exit 1\nend\n\nrequire_relative \"../config/environment\"\n\nserver = Server.find(ARGV[0])\nputs \"Got server #{server.name}\"\n\ntemplate = File.read(Rails.root.join(\"resource/postfix-bounce.msg\"))\n\nif ARGV[1].to_s =~ /\\A(\\d+)\\z/\n  message = server.message_db.message(ARGV[1].to_i)\n  puts \"Got message #{message.id} with token #{message.token}\"\n  template.gsub!(\"{{MSGID}}\", message.token)\nelse\n  template.gsub!(\"{{MSGID}}\", ARGV[1].to_s)\nend\n\nmessage = server.message_db.new_message\nmessage.scope = \"incoming\"\nmessage.rcpt_to = \"#{server.token}@#{Postal::Config.dns.return_path_domain}\"\nmessage.mail_from = \"MAILER-DAEMON@smtp.infra.atech.io\"\nmessage.raw_message = template\nmessage.bounce = true\nmessage.save\nputs \"Added message with id #{message.id}\"\n"
  },
  {
    "path": "script/make_user.rb",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\ntrap(\"INT\") do\n  puts\n  exit\nend\n\nrequire_relative \"../config/environment\"\n\nUserCreator.start do |u|\n  u.admin = true\n  u.email_verified_at = Time.now\nend\n"
  },
  {
    "path": "script/queue_size.rb",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\nrequire_relative \"../lib/postal/config\"\nrequire \"mysql2\"\n\nclient = Mysql2::Client.new(\n  host: Postal::Config.main_db.host,\n  username: Postal::Config.main_db.username,\n  password: Postal::Config.main_db.password,\n  port: Postal::Config.main_db.port,\n  database: Postal::Config.main_db.database\n)\nresult = client.query(\"SELECT COUNT(id) as size FROM `queued_messages` WHERE retry_after IS NULL OR \" \\\n                      \"retry_after <= ADDTIME(UTC_TIMESTAMP(), '30') AND locked_at IS NULL\")\nputs result.to_a.first[\"size\"]\n"
  },
  {
    "path": "script/send_html_email.rb",
    "content": "# frozen_string_literal: true\n\n# This script will automatically send an HTML email to the\n# SMTP server given.\n\nrequire \"mail\"\nrequire \"net/smtp\"\n\nfrom = ARGV[0]\nto = ARGV[1]\n\nif from.nil? || to.nil?\n  puts \"Usage: ruby send-html-email.rb <from> <to>\"\n  exit 1\nend\n\nmail = Mail.new\nmail.to = to\nmail.from = from\nmail.subject = \"A test email from #{Time.now}\"\nmail[\"X-Postal-Tag\"] = \"send-html-email-script\"\nmail.text_part = Mail::Part.new do\n  body <<~BODY\n    Hello there.\n\n    This is an example. It doesn't do all that much.\n\n    Some other characters: őúéáűí\n\n    There is a link here through... https://postalserver.io/test-plain-text-link?foo=bar&baz=qux\n  BODY\nend\nmail.html_part = Mail::Part.new do\n  content_type \"text/html; charset=UTF-8\"\n  body <<~BODY\n    <p>Hello there</p>\n    <p>This is an example email. It doesn't do all that much.</p>\n    <p>Some other characters: őúéáűí</p>\n    <p>There is a <a href='https://postalserver.io/test-plain-text-link?foo=bar&amp;baz=qux'>link here</a> though...</p>\n  BODY\nend\n\nc = OpenSSL::SSL::SSLContext.new\nc.verify_mode = OpenSSL::SSL::VERIFY_NONE\n\nsmtp = Net::SMTP.new(\"127.0.0.1\", 2525)\nsmtp.enable_starttls(c)\nsmtp.start(\"localhost\")\nsmtp.send_message mail.to_s, mail.from.first, mail.to.first\nsmtp.finish\n\nputs \"Sent\"\n"
  },
  {
    "path": "script/smtp_server.rb",
    "content": "# frozen_string_literal: true\n\n$stdout.sync = true\n$stderr.sync = true\n\nrequire_relative \"../config/environment\"\n\nHealthServer.start(\n  name: \"smtp-server\",\n  default_port: Postal::Config.smtp_server.default_health_server_port,\n  default_bind_address: Postal::Config.smtp_server.default_health_server_bind_address\n)\nSMTPServer::Server.new(debug: true).run\n"
  },
  {
    "path": "script/test_app_smtp.rb",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\ntrap(\"INT\") do\n  puts\n  exit\nend\n\nif ARGV[0].nil? || ARGV[0] !~ /@/\n  puts \"usage: postal test-app-smtp [email address]\"\n  exit 1\nend\n\nrequire_relative \"../config/environment\"\n\nbegin\n  Timeout.timeout(10) do\n    AppMailer.test_message(ARGV[0]).deliver\n  end\n\n  puts \"\\e[32mMessage has been sent successfully.\\e[0m\"\nrescue Timeout::Error\n  puts \"Sending timed out\"\nrescue StandardError => e\n  puts \"\\e[31mMessage was not delivered successfully to SMTP server.\\e[0m\"\n  puts \"Error: #{e.class} (#{e.message})\"\n  puts\n  puts \"  SMTP Host: #{Postal::Config.smtp.host}\"\n  puts \"  SMTP Port: #{Postal::Config.smtp.port}\"\n  puts \"  SMTP Username: #{Postal::Config.smtp.username}\"\n  puts \"  SMTP Password: #{Postal::Config.smtp.password}\"\n  puts\nend\n"
  },
  {
    "path": "script/version.rb",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\nrequire File.expand_path(\"../lib/postal/config\", __dir__)\nputs Postal.version\n"
  },
  {
    "path": "script/worker.rb",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\n$stdout.sync = true\n$stderr.sync = true\n\nrequire_relative \"../config/environment\"\n\nHealthServer.start(\n  name: \"worker\",\n  default_port: Postal::Config.worker.default_health_server_port,\n  default_bind_address: Postal::Config.worker.default_health_server_bind_address\n)\n\nWorker::Process.new.run\n"
  },
  {
    "path": "spec/apis/legacy_api/messages/deliveries_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nRSpec.describe \"Legacy Messages API\", type: :request do\n  describe \"/api/v1/messages/deliveries\" do\n    context \"when no authentication is provided\" do\n      it \"returns an error\" do\n        post \"/api/v1/messages/deliveries\"\n        expect(response.status).to eq 200\n        parsed_body = JSON.parse(response.body)\n        expect(parsed_body[\"status\"]).to eq \"error\"\n        expect(parsed_body[\"data\"][\"code\"]).to eq \"AccessDenied\"\n      end\n    end\n\n    context \"when the credential does not match anything\" do\n      it \"returns an error\" do\n        post \"/api/v1/messages/deliveries\", headers: { \"x-server-api-key\" => \"invalid\" }\n        expect(response.status).to eq 200\n        parsed_body = JSON.parse(response.body)\n        expect(parsed_body[\"status\"]).to eq \"error\"\n        expect(parsed_body[\"data\"][\"code\"]).to eq \"InvalidServerAPIKey\"\n      end\n    end\n\n    context \"when the credential belongs to a suspended server\" do\n      it \"returns an error\" do\n        server = create(:server, :suspended)\n        credential = create(:credential, server: server)\n        post \"/api/v1/messages/deliveries\", headers: { \"x-server-api-key\" => credential.key }\n        expect(response.status).to eq 200\n        parsed_body = JSON.parse(response.body)\n        expect(parsed_body[\"status\"]).to eq \"error\"\n        expect(parsed_body[\"data\"][\"code\"]).to eq \"ServerSuspended\"\n      end\n    end\n\n    context \"when the credential is valid\" do\n      let(:server) { create(:server) }\n      let(:credential) { create(:credential, server: server) }\n\n      context \"when no message ID is provided\" do\n        it \"returns an error\" do\n          post \"/api/v1/messages/deliveries\", headers: { \"x-server-api-key\" => credential.key }\n          expect(response.status).to eq 200\n          parsed_body = JSON.parse(response.body)\n          expect(parsed_body[\"status\"]).to eq \"parameter-error\"\n          expect(parsed_body[\"data\"][\"message\"]).to match(/`id` parameter is required but is missing/)\n        end\n      end\n\n      context \"when the message ID does not exist\" do\n        it \"returns an error\" do\n          post \"/api/v1/messages/deliveries\",\n               headers: { \"x-server-api-key\" => credential.key,\n                          \"content-type\" => \"application/json\" },\n               params: { id: 123 }.to_json\n          expect(response.status).to eq 200\n          parsed_body = JSON.parse(response.body)\n          expect(parsed_body[\"status\"]).to eq \"error\"\n          expect(parsed_body[\"data\"][\"code\"]).to eq \"MessageNotFound\"\n          expect(parsed_body[\"data\"][\"id\"]).to eq 123\n        end\n      end\n\n      context \"when the message ID exists\" do\n        let(:server) { create(:server) }\n        let(:credential) { create(:credential, server: server) }\n        let(:message) { MessageFactory.outgoing(server) }\n\n        before do\n          message.create_delivery(\"SoftFail\", details: \"no server found\",\n                                              output: \"404\",\n                                              sent_with_ssl: true,\n                                              log_id: \"1234\",\n                                              time: 1.2)\n          message.create_delivery(\"Sent\", details: \"sent successfully\",\n                                          output: \"200\",\n                                          sent_with_ssl: false,\n                                          log_id: \"5678\",\n                                          time: 2.2)\n        end\n\n        before do\n          post \"/api/v1/messages/deliveries\",\n               headers: { \"x-server-api-key\" => credential.key,\n                          \"content-type\" => \"application/json\" },\n               params: { id: message.id }.to_json\n        end\n\n        it \"returns an array of deliveries\" do\n          expect(response.status).to eq 200\n          parsed_body = JSON.parse(response.body)\n          expect(parsed_body[\"status\"]).to eq \"success\"\n          expect(parsed_body[\"data\"]).to match([\n                                                 { \"id\" => kind_of(Integer),\n                                                   \"status\" => \"SoftFail\",\n                                                   \"details\" => \"no server found\",\n                                                   \"output\" => \"404\",\n                                                   \"sent_with_ssl\" => true,\n                                                   \"log_id\" => \"1234\",\n                                                   \"time\" => 1.2,\n                                                   \"timestamp\" => kind_of(Float) },\n                                                 { \"id\" => kind_of(Integer),\n                                                   \"status\" => \"Sent\",\n                                                   \"details\" => \"sent successfully\",\n                                                   \"output\" => \"200\",\n                                                   \"sent_with_ssl\" => false,\n                                                   \"log_id\" => \"5678\",\n                                                   \"time\" => 2.2,\n                                                   \"timestamp\" => kind_of(Float) },\n                                               ])\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/apis/legacy_api/messages/message_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nRSpec.describe \"Legacy Messages API\", type: :request do\n  describe \"/api/v1/messages/message\" do\n    context \"when no authentication is provided\" do\n      it \"returns an error\" do\n        post \"/api/v1/messages/message\"\n        expect(response.status).to eq 200\n        parsed_body = JSON.parse(response.body)\n        expect(parsed_body[\"status\"]).to eq \"error\"\n        expect(parsed_body[\"data\"][\"code\"]).to eq \"AccessDenied\"\n      end\n    end\n\n    context \"when the credential does not match anything\" do\n      it \"returns an error\" do\n        post \"/api/v1/messages/message\", headers: { \"x-server-api-key\" => \"invalid\" }\n        expect(response.status).to eq 200\n        parsed_body = JSON.parse(response.body)\n        expect(parsed_body[\"status\"]).to eq \"error\"\n        expect(parsed_body[\"data\"][\"code\"]).to eq \"InvalidServerAPIKey\"\n      end\n    end\n\n    context \"when the credential belongs to a suspended server\" do\n      it \"returns an error\" do\n        server = create(:server, :suspended)\n        credential = create(:credential, server: server)\n        post \"/api/v1/messages/message\", headers: { \"x-server-api-key\" => credential.key }\n        expect(response.status).to eq 200\n        parsed_body = JSON.parse(response.body)\n        expect(parsed_body[\"status\"]).to eq \"error\"\n        expect(parsed_body[\"data\"][\"code\"]).to eq \"ServerSuspended\"\n      end\n    end\n\n    context \"when the credential is valid\" do\n      let(:server) { create(:server) }\n      let(:credential) { create(:credential, server: server) }\n\n      context \"when no message ID is provided\" do\n        it \"returns an error\" do\n          post \"/api/v1/messages/message\", headers: { \"x-server-api-key\" => credential.key }\n          expect(response.status).to eq 200\n          parsed_body = JSON.parse(response.body)\n          expect(parsed_body[\"status\"]).to eq \"parameter-error\"\n          expect(parsed_body[\"data\"][\"message\"]).to match(/`id` parameter is required but is missing/)\n        end\n      end\n\n      context \"when the message ID does not exist\" do\n        it \"returns an error\" do\n          post \"/api/v1/messages/message\",\n               headers: { \"x-server-api-key\" => credential.key,\n                          \"content-type\" => \"application/json\" },\n               params: { id: 123 }.to_json\n          expect(response.status).to eq 200\n          parsed_body = JSON.parse(response.body)\n          expect(parsed_body[\"status\"]).to eq \"error\"\n          expect(parsed_body[\"data\"][\"code\"]).to eq \"MessageNotFound\"\n        end\n      end\n\n      context \"when the message ID exists\" do\n        let(:server) { create(:server) }\n        let(:credential) { create(:credential, server: server) }\n        let(:message) { MessageFactory.outgoing(server) }\n        let(:expansions) { [] }\n\n        before do\n          post \"/api/v1/messages/message\",\n               headers: { \"x-server-api-key\" => credential.key,\n                          \"content-type\" => \"application/json\" },\n               params: { id: message.id, _expansions: expansions }.to_json\n        end\n\n        context \"when no expansions are requested\" do\n          it \"returns details about the message\" do\n            expect(response.status).to eq 200\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"success\"\n            expect(parsed_body[\"data\"]).to match({\n              \"id\" => message.id,\n              \"token\" => message.token\n            })\n          end\n        end\n\n        context \"when all expansions are requested\" do\n          let(:expansions) { true }\n\n          it \"returns details about the message\" do\n            expect(response.status).to eq 200\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"success\"\n            expect(parsed_body[\"data\"]).to match({\n              \"id\" => message.id,\n              \"token\" => message.token,\n              \"status\" => { \"held\" => false,\n                            \"hold_expiry\" => nil,\n                            \"last_delivery_attempt\" => nil,\n                            \"status\" => \"Pending\" },\n              \"details\" => { \"bounce\" => false,\n                             \"bounce_for_id\" => 0,\n                             \"direction\" => \"outgoing\",\n                             \"mail_from\" => \"test@example.com\",\n                             \"message_id\" => message.message_id,\n                             \"rcpt_to\" => \"john@example.com\",\n                             \"received_with_ssl\" => nil,\n                             \"size\" => kind_of(String),\n                             \"subject\" => \"An example message\",\n                             \"tag\" => nil,\n                             \"timestamp\" => kind_of(Float) },\n              \"inspection\" => { \"inspected\" => false,\n                                \"spam\" => false,\n                                \"spam_score\" => 0.0,\n                                \"threat\" => false,\n                                \"threat_details\" => nil },\n              \"plain_body\" => message.plain_body,\n              \"html_body\" => message.html_body,\n              \"attachments\" => [],\n              \"headers\" => message.headers,\n              \"raw_message\" => Base64.encode64(message.raw_message),\n              \"activity_entries\" => {\n                \"loads\" => [],\n                \"clicks\" => []\n              }\n            })\n          end\n        end\n\n        context \"when the status expansion is requested\" do\n          let(:expansions) { [\"status\"] }\n\n          it \"returns details about the message\" do\n            expect(response.status).to eq 200\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"success\"\n            expect(parsed_body[\"data\"]).to match({\n              \"id\" => message.id,\n              \"token\" => message.token,\n              \"status\" => { \"held\" => false,\n                            \"hold_expiry\" => nil,\n                            \"last_delivery_attempt\" => nil,\n                            \"status\" => \"Pending\" }\n            })\n          end\n        end\n\n        context \"when the details expansion is requested\" do\n          let(:expansions) { [\"details\"] }\n\n          it \"returns details about the message\" do\n            expect(response.status).to eq 200\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"success\"\n            expect(parsed_body[\"data\"]).to match({\n              \"id\" => message.id,\n              \"token\" => message.token,\n              \"details\" => { \"bounce\" => false,\n                             \"bounce_for_id\" => 0,\n                             \"direction\" => \"outgoing\",\n                             \"mail_from\" => \"test@example.com\",\n                             \"message_id\" => message.message_id,\n                             \"rcpt_to\" => \"john@example.com\",\n                             \"received_with_ssl\" => nil,\n                             \"size\" => kind_of(String),\n                             \"subject\" => \"An example message\",\n                             \"tag\" => nil,\n                             \"timestamp\" => kind_of(Float) }\n            })\n          end\n        end\n\n        context \"when the details expansion is requested\" do\n          let(:expansions) { [\"inspection\"] }\n\n          it \"returns details about the message\" do\n            expect(response.status).to eq 200\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"success\"\n            expect(parsed_body[\"data\"]).to match({\n              \"id\" => message.id,\n              \"token\" => message.token,\n              \"inspection\" => { \"inspected\" => false,\n                                \"spam\" => false,\n                                \"spam_score\" => 0.0,\n                                \"threat\" => false,\n                                \"threat_details\" => nil }\n            })\n          end\n        end\n\n        context \"when the body expansions are requested\" do\n          let(:expansions) { %w[plain_body html_body] }\n\n          it \"returns details about the message\" do\n            expect(response.status).to eq 200\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"success\"\n            expect(parsed_body[\"data\"]).to match({\n              \"id\" => message.id,\n              \"token\" => message.token,\n              \"plain_body\" => message.plain_body,\n              \"html_body\" => message.html_body\n            })\n          end\n        end\n\n        context \"when the attachments expansions is requested\" do\n          let(:message) do\n            MessageFactory.outgoing(server) do |_, mail|\n              mail.attachments[\"example.txt\"] = \"hello world!\"\n            end\n          end\n          let(:expansions) { [\"attachments\"] }\n\n          it \"returns details about the message\" do\n            expect(response.status).to eq 200\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"success\"\n            expect(parsed_body[\"data\"]).to match({\n              \"id\" => message.id,\n              \"token\" => message.token,\n              \"attachments\" => [\n                {\n                  \"content_type\" => \"text/plain\",\n                  \"data\" => Base64.encode64(\"hello world!\"),\n                  \"filename\" => \"example.txt\",\n                  \"hash\" => Digest::SHA1.hexdigest(\"hello world!\"),\n                  \"size\" => 12\n                },\n              ]\n            })\n          end\n        end\n\n        context \"when the headers expansions is requested\" do\n          let(:expansions) { [\"headers\"] }\n\n          it \"returns details about the message\" do\n            expect(response.status).to eq 200\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"success\"\n            expect(parsed_body[\"data\"]).to match({\n              \"id\" => message.id,\n              \"token\" => message.token,\n              \"headers\" => message.headers\n            })\n          end\n        end\n\n        context \"when the raw_message expansions is requested\" do\n          let(:expansions) { [\"raw_message\"] }\n\n          it \"returns details about the message\" do\n            expect(response.status).to eq 200\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"success\"\n            expect(parsed_body[\"data\"]).to match({\n              \"id\" => message.id,\n              \"token\" => message.token,\n              \"raw_message\" => Base64.encode64(message.raw_message)\n            })\n          end\n        end\n\n        context \"when the activity_entries expansions is requested\" do\n          let(:message) do\n            MessageFactory.outgoing(server) do |msg|\n              msg.create_load(double(\"request\", ip: \"1.2.3.4\", user_agent: \"user agent\"))\n              link = msg.create_link(\"https://example.com\")\n              link_id = msg.database.select(:links, where: { token: link }).first[\"id\"]\n              msg.database.insert(:clicks, {\n                message_id: msg.id,\n                link_id: link_id,\n                ip_address: \"1.2.3.4\",\n                user_agent: \"user agent\",\n                timestamp: Time.now.to_f\n              })\n            end\n          end\n          let(:expansions) { [\"activity_entries\"] }\n\n          it \"returns details about the message\" do\n            expect(response.status).to eq 200\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"success\"\n            expect(parsed_body[\"data\"]).to match({\n              \"id\" => message.id,\n              \"token\" => message.token,\n              \"activity_entries\" => {\n                \"loads\" => [{\n                  \"ip_address\" => \"1.2.3.4\",\n                  \"user_agent\" => \"user agent\",\n                  \"timestamp\" => match(/\\A\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+Z\\z/)\n                }],\n                \"clicks\" => [{\n                  \"url\" => \"https://example.com\",\n                  \"ip_address\" => \"1.2.3.4\",\n                  \"user_agent\" => \"user agent\",\n                  \"timestamp\" => match(/\\A\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+Z\\z/)\n                }]\n              }\n            })\n          end\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/apis/legacy_api/send/message_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nRSpec.describe \"Legacy Send API\", type: :request do\n  describe \"/api/v1/send/message\" do\n    context \"when no authentication is provided\" do\n      it \"returns an error\" do\n        post \"/api/v1/send/message\"\n        expect(response.status).to eq 200\n        parsed_body = JSON.parse(response.body)\n        expect(parsed_body[\"status\"]).to eq \"error\"\n        expect(parsed_body[\"data\"][\"code\"]).to eq \"AccessDenied\"\n      end\n    end\n\n    context \"when the credential does not match anything\" do\n      it \"returns an error\" do\n        post \"/api/v1/send/message\", headers: { \"x-server-api-key\" => \"invalid\" }\n        expect(response.status).to eq 200\n        parsed_body = JSON.parse(response.body)\n        expect(parsed_body[\"status\"]).to eq \"error\"\n        expect(parsed_body[\"data\"][\"code\"]).to eq \"InvalidServerAPIKey\"\n      end\n    end\n\n    context \"when the credential belongs to a suspended server\" do\n      it \"returns an error\" do\n        server = create(:server, :suspended)\n        credential = create(:credential, server: server)\n        post \"/api/v1/send/message\", headers: { \"x-server-api-key\" => credential.key }\n        expect(response.status).to eq 200\n        parsed_body = JSON.parse(response.body)\n        expect(parsed_body[\"status\"]).to eq \"error\"\n        expect(parsed_body[\"data\"][\"code\"]).to eq \"ServerSuspended\"\n      end\n    end\n\n    context \"when the credential is valid\" do\n      let(:server) { create(:server) }\n      let(:credential) { create(:credential, server: server) }\n      let(:domain) { create(:domain, owner: server) }\n\n      context \"when parameters are provided in a JSON body\" do\n        let(:default_params) do\n          {\n            to: [\"test@example.com\"],\n            cc: [\"cc@example.com\"],\n            bcc: [\"bcc@example.com\"],\n            from: \"test@#{domain.name}\",\n            sender: \"sender@#{domain.name}\",\n            tag: \"test-tag\",\n            reply_to: \"reply@example.com\",\n            plain_body: \"plain text\",\n            html_body: \"<p>html</p>\",\n            attachments: [{ name: \"test1.txt\", content_type: \"text/plain\", data: Base64.encode64(\"hello world 1\") },\n                          { name: \"test2.txt\", content_type: \"text/plain\", data: Base64.encode64(\"hello world 2\") },],\n            headers: { \"x-test-header-1\" => \"111\", \"x-test-header-2\" => \"222\" },\n            bounce: false,\n            subject: \"Test\"\n          }\n        end\n        let(:params) { default_params }\n\n        before do\n          post \"/api/v1/send/message\",\n               headers: { \"x-server-api-key\" => credential.key,\n                          \"content-type\" => \"application/json\" },\n               params: params.to_json\n        end\n\n        context \"when no recipients are provided\" do\n          let(:params) { default_params.merge(to: [], cc: [], bcc: []) }\n\n          it \"returns an error\" do\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"error\"\n            expect(parsed_body[\"data\"][\"code\"]).to eq \"NoRecipients\"\n            expect(parsed_body[\"data\"][\"message\"]).to match(/there are no recipients defined to receive this message/i)\n          end\n        end\n\n        context \"when no content is provided\" do\n          let(:params) { default_params.merge(html_body: nil, plain_body: nil) }\n\n          it \"returns an error\" do\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"error\"\n            expect(parsed_body[\"data\"][\"code\"]).to eq \"NoContent\"\n            expect(parsed_body[\"data\"][\"message\"]).to match(/there is no content defined for this e-mail/i)\n          end\n        end\n\n        context \"when the number of 'To' recipients exceeds the maximum\" do\n          let(:params) { default_params.merge(to: [\"a@a.com\"] * 51) }\n\n          it \"returns an error\" do\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"error\"\n            expect(parsed_body[\"data\"][\"code\"]).to eq \"TooManyToAddresses\"\n            expect(parsed_body[\"data\"][\"message\"]).to match(/the maximum number of To addresses has been reached/i)\n          end\n        end\n\n        context \"when the number of 'CC' recipients exceeds the maximum\" do\n          let(:params) { default_params.merge(cc: [\"a@a.com\"] * 51) }\n\n          it \"returns an error\" do\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"error\"\n            expect(parsed_body[\"data\"][\"code\"]).to eq \"TooManyCCAddresses\"\n            expect(parsed_body[\"data\"][\"message\"]).to match(/the maximum number of CC addresses has been reached/i)\n          end\n        end\n\n        context \"when the number of 'BCC' recipients exceeds the maximum\" do\n          let(:params) { default_params.merge(bcc: [\"a@a.com\"] * 51) }\n\n          it \"returns an error\" do\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"error\"\n            expect(parsed_body[\"data\"][\"code\"]).to eq \"TooManyBCCAddresses\"\n            expect(parsed_body[\"data\"][\"message\"]).to match(/the maximum number of BCC addresses has been reached/i)\n          end\n        end\n\n        context \"when the 'From' address is missing\" do\n          let(:params) { default_params.merge(from: nil) }\n\n          it \"returns an error\" do\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"error\"\n            expect(parsed_body[\"data\"][\"code\"]).to eq \"FromAddressMissing\"\n            expect(parsed_body[\"data\"][\"message\"]).to match(/the from address is missing and is required/i)\n          end\n        end\n\n        context \"when the 'From' address is not authorised\" do\n          let(:params) { default_params.merge(from: \"test@another.com\") }\n\n          it \"returns an error\" do\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"error\"\n            expect(parsed_body[\"data\"][\"code\"]).to eq \"UnauthenticatedFromAddress\"\n            expect(parsed_body[\"data\"][\"message\"]).to match(/the from address is not authorised to send mail from this server/i)\n          end\n        end\n\n        context \"when an attachment is missing a name\" do\n          let(:params) { default_params.merge(attachments: [{ name: nil, content_type: \"text/plain\", data: Base64.encode64(\"hello world 1\") }]) }\n\n          it \"returns an error\" do\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"error\"\n            expect(parsed_body[\"data\"][\"code\"]).to eq \"AttachmentMissingName\"\n            expect(parsed_body[\"data\"][\"message\"]).to match(/an attachment is missing a name/i)\n          end\n        end\n\n        context \"when an attachment is missing data\" do\n          let(:params) { default_params.merge(attachments: [{ name: \"test1.txt\", content_type: \"text/plain\", data: nil }]) }\n\n          it \"returns an error\" do\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"error\"\n            expect(parsed_body[\"data\"][\"code\"]).to eq \"AttachmentMissingData\"\n            expect(parsed_body[\"data\"][\"message\"]).to match(/an attachment is missing data/i)\n          end\n        end\n\n        context \"when an attachment entry is not a hash\" do\n          let(:params) { default_params.merge(attachments: [123, \"string\"]) }\n\n          it \"continues as if it wasn't there\" do\n            parsed_body = JSON.parse(response.body)\n            [\"test@example.com\", \"cc@example.com\", \"bcc@example.com\"].each do |rcpt_to|\n              message_id = parsed_body[\"data\"][\"messages\"][rcpt_to][\"id\"]\n              message = server.message(message_id)\n              expect(message.attachments).to be_empty\n            end\n          end\n        end\n\n        context \"when given a complete email to send\" do\n          it \"returns details of the messages created\" do\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"status\"]).to eq \"success\"\n            expect(parsed_body[\"data\"][\"messages\"]).to match({\n              \"test@example.com\" => { \"id\" => kind_of(Integer), \"token\" => /\\A[a-zA-Z0-9]{16}\\z/ },\n              \"cc@example.com\" => { \"id\" => kind_of(Integer), \"token\" => /\\A[a-zA-Z0-9]{16}\\z/ },\n              \"bcc@example.com\" => { \"id\" => kind_of(Integer), \"token\" => /\\A[a-zA-Z0-9]{16}\\z/ }\n            })\n          end\n\n          it \"adds an appropriate received header\" do\n            parsed_body = JSON.parse(response.body)\n            message_id = parsed_body[\"data\"][\"messages\"][\"test@example.com\"][\"id\"]\n            message = server.message(message_id)\n            expect(message.headers[\"received\"].first).to match(/\\Afrom api/)\n          end\n\n          it \"creates appropriate message objects\" do\n            parsed_body = JSON.parse(response.body)\n            [\"test@example.com\", \"cc@example.com\", \"bcc@example.com\"].each do |rcpt_to|\n              message_id = parsed_body[\"data\"][\"messages\"][rcpt_to][\"id\"]\n              message = server.message(message_id)\n              expect(message).to have_attributes(\n                server: server,\n                rcpt_to: rcpt_to,\n                mail_from: params[:from],\n                subject: params[:subject],\n                message_id: kind_of(String),\n                timestamp: kind_of(Time),\n                domain_id: domain.id,\n                credential_id: credential.id,\n                bounce: false,\n                tag: params[:tag],\n                headers: hash_including(\"x-test-header-1\" => [\"111\"],\n                                        \"x-test-header-2\" => [\"222\"],\n                                        \"sender\" => [params[:sender]],\n                                        \"to\" => [\"test@example.com\"],\n                                        \"cc\" => [\"cc@example.com\"],\n                                        \"reply-to\" => [\"reply@example.com\"]),\n                plain_body: params[:plain_body],\n                html_body: params[:html_body],\n                attachments: [\n                  have_attributes(content_type: /\\Atext\\/plain/, filename: \"test1.txt\", body: have_attributes(to_s: \"hello world 1\")),\n                  have_attributes(content_type: /\\Atext\\/plain/, filename: \"test2.txt\", body: have_attributes(to_s: \"hello world 2\")),\n                ]\n              )\n            end\n          end\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/apis/legacy_api/send/raw_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nRSpec.describe \"Legacy Send API\", type: :request do\n  describe \"/api/v1/send/raw\" do\n    context \"when no authentication is provided\" do\n      it \"returns an error\" do\n        post \"/api/v1/send/raw\"\n        expect(response.status).to eq 200\n        parsed_body = JSON.parse(response.body)\n        expect(parsed_body[\"status\"]).to eq \"error\"\n        expect(parsed_body[\"data\"][\"code\"]).to eq \"AccessDenied\"\n      end\n    end\n\n    context \"when the credential does not match anything\" do\n      it \"returns an error\" do\n        post \"/api/v1/send/raw\", headers: { \"x-server-api-key\" => \"invalid\" }\n        expect(response.status).to eq 200\n        parsed_body = JSON.parse(response.body)\n        expect(parsed_body[\"status\"]).to eq \"error\"\n        expect(parsed_body[\"data\"][\"code\"]).to eq \"InvalidServerAPIKey\"\n      end\n    end\n\n    context \"when the credential belongs to a suspended server\" do\n      it \"returns an error\" do\n        server = create(:server, :suspended)\n        credential = create(:credential, server: server)\n        post \"/api/v1/send/raw\", headers: { \"x-server-api-key\" => credential.key }\n        expect(response.status).to eq 200\n        parsed_body = JSON.parse(response.body)\n        expect(parsed_body[\"status\"]).to eq \"error\"\n        expect(parsed_body[\"data\"][\"code\"]).to eq \"ServerSuspended\"\n      end\n    end\n\n    context \"when the credential is valid\" do\n      let(:server) { create(:server) }\n      let(:credential) { create(:credential, server: server) }\n      let(:domain) { create(:domain, owner: server) }\n      let(:data) do\n        mail = Mail.new\n        mail.to = \"test1@example.com\"\n        mail.from = \"test@#{domain.name}\"\n        mail.subject = \"test\"\n        mail.text_part = Mail::Part.new\n        mail.text_part.body = \"plain text\"\n        mail.html_part = Mail::Part.new\n        mail.html_part.content_type = \"text/html; charset=UTF-8\"\n        mail.html_part.body = \"<p>html</p>\"\n        mail\n      end\n      let(:default_params) do\n        {\n          mail_from: \"test@#{domain.name}\",\n          rcpt_to: [\"test1@example.com\", \"test2@example.com\"],\n          data: Base64.encode64(data.to_s),\n          bounce: false\n        }\n      end\n      let(:content_type) { \"application/json\" }\n      let(:params) { default_params }\n\n      before do\n        post \"/api/v1/send/raw\",\n             headers: { \"x-server-api-key\" => credential.key,\n                        \"content-type\" => content_type },\n             params: content_type == \"application/json\" ? params.to_json : params\n      end\n\n      context \"when rcpt_to is not provided\" do\n        let(:params) { default_params.except(:rcpt_to) }\n\n        it \"returns an error\" do\n          parsed_body = JSON.parse(response.body)\n          expect(parsed_body[\"status\"]).to eq \"parameter-error\"\n          expect(parsed_body[\"data\"][\"message\"]).to match(/`rcpt_to` parameter is required but is missing/i)\n        end\n      end\n\n      context \"when mail_from is not provided\" do\n        let(:params) { default_params.except(:mail_from) }\n\n        it \"returns an error\" do\n          parsed_body = JSON.parse(response.body)\n          expect(parsed_body[\"status\"]).to eq \"parameter-error\"\n          expect(parsed_body[\"data\"][\"message\"]).to match(/`mail_from` parameter is required but is missing/i)\n        end\n      end\n\n      context \"when data is not provided\" do\n        let(:params) { default_params.except(:data) }\n\n        it \"returns an error\" do\n          parsed_body = JSON.parse(response.body)\n          expect(parsed_body[\"status\"]).to eq \"parameter-error\"\n          expect(parsed_body[\"data\"][\"message\"]).to match(/`data` parameter is required but is missing/i)\n        end\n      end\n\n      context \"when no recipients are provided\" do\n        let(:params) { default_params.merge(rcpt_to: []) }\n\n        it \"returns success but with no messages\" do\n          parsed_body = JSON.parse(response.body)\n          expect(parsed_body[\"status\"]).to eq \"success\"\n          expect(parsed_body[\"data\"][\"messages\"]).to eq({})\n          expect(parsed_body[\"data\"][\"message_id\"]).to be nil\n        end\n      end\n\n      context \"when a valid email is provided\" do\n        it \"returns details of the messages created\" do\n          parsed_body = JSON.parse(response.body)\n          expect(parsed_body[\"data\"][\"message_id\"]).to be_a String\n          expect(parsed_body[\"data\"][\"messages\"]).to be_a Hash\n          expect(parsed_body[\"data\"][\"messages\"]).to match({\n            \"test1@example.com\" => { \"id\" => kind_of(Integer), \"token\" => /\\A[a-zA-Z0-9]{16}\\z/ },\n            \"test2@example.com\" => { \"id\" => kind_of(Integer), \"token\" => /\\A[a-zA-Z0-9]{16}\\z/ }\n          })\n        end\n\n        it \"creates appropriate message objects\" do\n          parsed_body = JSON.parse(response.body)\n          [\"test1@example.com\", \"test2@example.com\"].each do |rcpt_to|\n            message_id = parsed_body[\"data\"][\"messages\"][rcpt_to][\"id\"]\n            message = server.message(message_id)\n            expect(message).to have_attributes(\n              server: server,\n              rcpt_to: rcpt_to,\n              mail_from: \"test@#{domain.name}\",\n              subject: \"test\",\n              message_id: kind_of(String),\n              timestamp: kind_of(Time),\n              domain_id: domain.id,\n              credential_id: credential.id,\n              bounce: false,\n              headers: hash_including(\"to\" => [\"test1@example.com\"]),\n              plain_body: \"plain text\",\n              html_body: \"<p>html</p>\",\n              attachments: [],\n              received_with_ssl: true,\n              scope: \"outgoing\",\n              raw_message: data.to_s\n            )\n          end\n        end\n\n        context \"when params are provided as a param\" do\n          let(:content_type) { nil }\n          let(:params) { { params: default_params.to_json } }\n\n          it \"returns details of the messages created\" do\n            parsed_body = JSON.parse(response.body)\n            expect(parsed_body[\"data\"][\"message_id\"]).to be_a String\n            expect(parsed_body[\"data\"][\"messages\"]).to be_a Hash\n            expect(parsed_body[\"data\"][\"messages\"]).to match({\n              \"test1@example.com\" => { \"id\" => kind_of(Integer), \"token\" => /\\A[a-zA-Z0-9]{16}\\z/ },\n              \"test2@example.com\" => { \"id\" => kind_of(Integer), \"token\" => /\\A[a-zA-Z0-9]{16}\\z/ }\n            })\n          end\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/examples/dkim_signing/email1.msg",
    "content": "domain: adamtest.viaduct.io\ntime: 1626973299\ndkim_identifier: postal-hg3YOm\nprivate_key: |\n  -----BEGIN RSA PRIVATE KEY-----\n  MIICWwIBAAKBgQDVebmUOjOp4WhVFdwXiHE0sIN0/pxG1lXka8b7wCcKXo7c0Bcp\n  2t77AUP3Lr3HOdY1OKYU8UzbLR5dahvwoodEo2QgFB6wxP9FF/koip+YdmWSscZI\n  9xMZBPcJYfMa3MtC2JC0aDHaKRTSlehg9/T77dJEL11pePdKJtyWZsxNlwIDAQAB\n  AoGAYBdWzb4VG1b3W7VnSMCGFK2Pvs4NEmXQa+2HuDKaYDSIIiUZCCIZVOsQ6OcF\n  TfRe074YJD0p107L6EinIv5F3HEp6pMT3gzo057mPabskohUXbDXcX3kq+0EDMYx\n  1HmlyRvWb6CI79hZrJavF5vOPZxSnM123Y7L0wc1WEKPxSECQQDq3iH/JNPmwDBS\n  DeY6KR6+q7hKGmvcfWfiPQh+PYaekE6635+au3qE8sp+xwiQDbsq9ywwj8J/8Lzt\n  FnvSi+/DAkEA6K7Zw8OHZpq3QwEfazTSm2A+iciD88ISl4g+71Cu6AVnaip5KdxI\n  yiEWE3z/3Y99NPyL1AGouohBCqHD3qOBnQJAWumRD0oaG//YtGpc67ZvCC9ALq77\n  gWWpiJFHcFYwfcAuOXfGOAbJ7hxs9ZXlYp1uDbuPh1yeVRfCiaNiWqWAMQJAJnuc\n  qoLxJugZvSw3XQy8dFQjo7gVEsCbQJKZDg2DD/6szuM9bM3w//Ue6JQ44RT1OUk3\n  exXXKRqV30NH2M+kBQJAJmVIeroVONXhsw/O18dtM4zSbr4glPKBHz73MVO3WK5v\n  ZM1TKedFNOujh8cEyYsoQRaIbGvTcnSMQfMYw7Inhw==\n  -----END RSA PRIVATE KEY-----\nheaders: date:from:to:message-id:subject:mime-version:content-type:content-transfer-encoding\nbh: mvuu3b9qEfdaZCSaVxLvRSqKbMB5YN4qxRCRw5FtvuE=\nb: UZgYjsed5ngc9+U/80FMsHIWLTaxj/OBc5r1l9sKbOIz0rmdRnp+X/YCFq16/AY8PWsrJh+/FgIs/zR9hWn+F2iz7dSfvnTE70zKy1q+JIyNh5cw1HegnIgZqG5S6IdliiwaiCFlxMbya790z//jUh5pJdOcm2QuA+fDOGdfU3k=\n---\nDKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=adamtest.viaduct.io; s=postal-hg3YOm; t=1626973299; bh=mvuu3b9qEfdaZCSaVxLvRSqKbMB5YN4qxRCRw5FtvuE=; h=date:from:to:message-id:subject:mime-version:content-type:content-transfer-encoding; b=UZgYjsed5ngc9+U/80FMsHIWLTaxj/OBc5r1l9sKbOIz0rmdRnp+X/YCFq16/AY8PWsrJh+/FgIs/zR9hWn+F2iz7dSfvnTE70zKy1q+JIyNh5cw1HegnIgZqG5S6IdliiwaiCFlxMbya790z//jUh5pJdOcm2QuA+fDOGdfU3k=\nX-Postal-MsgID: mPVbUwFRWZcU\nReceived: from web-ui (10.210.10.54 [10.210.10.54]) by Postal with HTTP; Thu, 22 Jul 2021 17:01:39 +0000\nDate: Thu, 22 Jul 2021 18:01:39 +0100\nFrom: test@adamtest.viaduct.io\nTo: JfkFM0Sy6bTz1N@dkimvalidator.com\nMessage-ID: <d996f20d-c0c5-46d5-ba22-3bac0d859142@rp.adampostal.viaduct.io>\nSubject: Test Message at July 22, 2021 13:01\nMime-Version: 1.0\nContent-Type: text/plain;\n charset=UTF-8\nContent-Transfer-Encoding: 7bit\n\nThis is a message to test the delivery of messages through Postal.\n"
  },
  {
    "path": "spec/examples/dkim_signing/email2.msg",
    "content": "domain: adamtest.viaduct.io\ntime: 1627490307\ndkim_identifier: postal-R7w1LN\nprivate_key: |\n  -----BEGIN RSA PRIVATE KEY-----\n  MIICWwIBAAKBgQCwc2EEPeNKe1jS4cQ0GQXeRkm8vsDsD3UeR9EUdxLuvbzJ2VvZ\n  tRyFVLkUMmQ0WjOLZ+DU3u4CjfGp/xuNDJROmYd3itz7FKRxvOIGCYwu7ka5GSqP\n  V/aXyWRWalbo5RPbjfImnHGkKzYlTzvd7p+MkhY+0pCeXnWDWD9IVcGHjQIDAQAB\n  AoGAJj98Yi0AHd8K6/tgSmK6MOpPhYhbzU+0dXHf0m3VPscGK0LgdBqcKhKpY8Vg\n  jzCWR7umsr34HbmjDtRrpnF5nAuoevuK0CYI5a2u2fnc1Qygq4M3Ydq4MtKDA2Fs\n  i6b+h+DUpn2YRX1fYLAVIwJiRi0qYuDX63BdT0jsuaQyRAkCQQDlK+NTpVY3A1tu\n  wncAmax3gap3fmapZOEGhKIK+zHP3w13gztQdjcJ6v/5RMemiqYTKJcrlCEYFYGF\n  z94JvUs3AkEAxRt8gA9kQ8l1VcpdGurRAduCG/raeIrpD9MXlfY9c8+V+65mVjQ+\n  WuhbJ6fQHkA2MVuUWR+EJ1SELbDtgYoNWwJAGKxw/UB/18x0u6gUR+xDtVowkEz7\n  oKFL2PfOun/xDQBm4scuS6tuoZK7nIrbNAMZflaQcBCyv3URTObkcQgAYQJACG4j\n  hgqifC+6n/+2ubb/V3f++ZliDLPMQgwCPzy35iMjxA7ye49id1rmwyxvP0v5xWSo\n  VKN/cHsx6A5gKiEwbwJAV1Ll5Xqfj6p92rSXXP/cHG5cEvYFsPgYRPMd6qgVRvoQ\n  8CZWA1vhnm6M/tB7UExROAuqexsDrOlTPrphLEcMdg==\n  -----END RSA PRIVATE KEY-----\nheaders: content-transfer-encoding:content-type:to:message-id:date:mime-version:reply-to:from:subject:list-unsubscribe:list-id\nbh: z/984HsxoQhYgMjcdqgVI00vVOHB5H85L6FE9/7IG3k=\nb: AUgfU59IxHLLciftQ6eTuCabZp6ui5BoKxbfhN31yCR7YqRQM3I3vVxfu653zztyC2LbWVZ1oLPSCD/UlDKRtqWUtjXrI/br+7SmMRwv4VyCzpVeXp25w+f6UR1Y8VGdfh/Q2sfqZ1Sop5g6DdGpv1m5abKJ3/onX6vt79ZIMwA=\n---\nContent-Transfer-Encoding: quoted-printable\nContent-Type: text/html; charset=\"UTF-8\"\nTo: gf1gzhnlUUZ3Zo@dkimvalidator.com\nMessage-ID: <E6.0E.26872.BB6E0016@af.mta3vrest.cc.prd.sparkpost>\nDate: Wed, 28 Jul 2021 05:10:19 +0000\nMIME-Version: 1.0\nReply-To: mail@reply.perfect-quotes.com\nFrom: \"Birchmore Property\" <adam@adamtest.viaduct.io>\nSubject: Hassle-free buy-to-let opportunity with 8.5% NET returns\nX-Report-Abuse: mail@reply.perfect-quotes.com\nList-Unsubscribe: <mailto:unsubscribe@eu.sparkpostmail.com?subject=unsubscribe:pkMQwULJsqLL5bsTqDUpVjnB2gV0VDtO-6Q6tkEtuRo~|eyAicmNwdF90byI6ICJ0ZWFtQG9yaWVudG8udWsiLCAidGVuYW50X2lkIjogInNwY2V1IiwgImN1c3RvbWVyX2lkIjogIjExMDAxIiwgIm1lc3NhZ2VfaWQiOiAiNjEwMGJiZTYwMDYxMmUxMjZlZTAiLCAic3ViYWNjb3VudF9pZCI6ICIwIiB9>\nList-Id: <spceu.11001.0.sparkpostmail.com>\n\n<html xmlns=3D\"http://www.w3.org/1999/xhtml\" xmlns:v=3D\"urn:schemas-micro=\nsoft-com:vml\" xmlns:o=3D\"urn:schemas-microsoft-com:office:office\" style=3D=\n\"padding:0;width:100%;background-color:#f8f8f8;margin:0;\">\n<head><meta http-equiv=3D\"Content-Type\" content=3D\"text/html; charset=3DU=\nTF-8\">\n<!-- NAME: 1:3 COLUMN -->\n<!--[if gte mso 15]>\n    <xml>\n      <o:OfficeDocumentSettings>\n        <o:AllowPNG />\n        <o:PixelsPerInch>96</o:PixelsPerInch>\n      </o:OfficeDocumentSettings>\n    </xml>\n    <![endif]-->\n<meta charset=3D\"UTF-8\">\n<meta http-equiv=3D\"x-ua-compatible\" content=3D\"IE=3Dedge\">\n<meta name=3D\"viewport\" content=3D\"width=3Ddevice-width, initial-scale=3D=\n1\">\n<link href=3D\"https://fonts.googleapis.com/css2?family=3DPoppins:ital,wgh=\nt@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300=\n;1,400;1,500;1,600;1,700;1,800;1,900&amp;display=3Dswap\" rel=3D\"styleshee=\nt\">\n<style type=3D\"text/css\">\np {\n\tmargin: 10px 0;\n\tpadding: 0;\n}\ntable {\n\tborder-collapse: collapse;\n}\nh1, h2, h3, h4, h5, h6 {\n\tdisplay: block;\n\tmargin: 0;\n\tpadding: 0;\n}\nimg, a img {\n\tborder: 0;\n\theight: auto;\n\toutline: none;\n\ttext-decoration: none;\n}\nbody, #bodyTable, #bodyCell {\n\tmargin: 0;\n\tpadding: 0;\n\twidth: 100%;\n}\n.mcnPreviewText {\n\tdisplay: none !important;\n}\n#outlook a {\n\tpadding: 0;\n}\nimg {\n\t-ms-interpolation-mode: bicubic;\n}\ntable {\n\tmso-table-lspace: 0;\n\tmso-table-rspace: 0;\n}\n.ReadMsgBody {\n\twidth: 100%;\n}\n.ExternalClass {\n\twidth: 100%;\n}\np, a, li, td, blockquote {\n\tmso-line-height-rule: exactly;\n}\na[href^=3Dtel], a[href^=3Dsms] {\n\tcolor: inherit;\n\tcursor: default;\n\ttext-decoration: none;\n}\np, a, li, td, body, table, blockquote {\n\t-ms-text-size-adjust: 100%;\n\t-webkit-text-size-adjust: 100%;\n}\n.ExternalClass, .ExternalClass p, .ExternalClass td, .ExternalClass div, =\n.ExternalClass span, .ExternalClass font {\n\tline-height: 100%;\n}\na[x-apple-data-detectors] {\n\tcolor: inherit !important;\n\ttext-decoration: none !important;\n\tfont-size: inherit !important;\n\tfont-family: inherit !important;\n\tfont-weight: inherit !important;\n\tline-height: inherit !important;\n}\n#bodyCell {\n\tpadding: 10px;\n}\n.templateContainer {\n\tmax-width: 640px !important;\n}\n [style*=3DPoppins] {\n font-family:'Poppins', Arial, sans-serif !important;\n}\n.ReadMsgBody {\n\twidth: 100%;\n\tbackground-color: #f8f8f8;\n}\n.ExternalClass {\n\twidth: 100%;\n\tbackground-color: #f8f8f8;\n}\n.templateContainer {\n\tmax-width: 640px !important;\n}\n.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass fon=\nt, .ExternalClass td, .ExternalClass div {\n\tline-height: 100%;\n}\n#outlook a {\n\tpadding: 0;\n}\nhtml {\n\twidth: 100%;\n}\nbody, #bodyTable, #bodyCell {\n\tmargin: 0;\n\tpadding: 0;\n\twidth: 100%;\n}\nhtml, body {\n\tbackground-color: #f8f8f8;\n\tmargin: 0;\n\tpadding: 0;\n}\ntable {\n\tborder-spacing: 0;\n}\ntable td {\n\tborder-collapse: collapse;\n}\nbr, strong br, b br, em br, i br {\n\tline-height: 100%;\n}\nh1, h2, h3, h4, h5, h6 {\n\tline-height: 100% !important;\n\t-webkit-font-smoothing: antialiased;\n}\nimg {\n\theight: auto !important;\n\tline-height: 100%;\n\toutline: none;\n\ttext-decoration: none;\n\tdisplay: block !important;\n}\nspan a {\n\tcolor: #7a8b89 !important;\n\ttext-decoration: none !important;\n}\na {\n\ttext-decoration: none !important;\n}\n.whitebutton a {\n\tcolor: #ffffff !important;\n\ttext-decoration: underline !important;\n}\n.footer a {\n\ttext-decoration: none !important;\n}\ntable p {\n\tmargin: 0;\n}\n.yshortcuts, .yshortcuts a, .yshortcuts a:link, .yshortcuts a:visited, .y=\nshortcuts a:hover, .yshortcuts a span {\n\ttext-decoration: none !important;\n\tborder-bottom: none !important;\n}\ntable {\n\tmso-table-lspace: 0;\n\tmso-table-rspace: 0;\n}\nimg {\n\t-ms-interpolation-mode: bicubic;\n}\nbody {\n\t-webkit-text-size-adjust: 100%;\n}\nbody {\n\t-ms-text-size-adjust: 100%;\n}\nimg {\n\theight: auto !important;\n}\n.hide_on_mobile {\n}\n @media only screen and (max-width: 650px) {\nbody {\n\twidth: auto !important;\n}\n.container {\n\twidth: 95% !important;\n\tpadding-left: 20px !important;\n\tpadding-right: 20px !important;\n}\n.image-100-percent img {\n\twidth: 100% !important;\n\theight: auto !important;\n\tmax-width: 100% !important;\n}\n.full-width {\n\twidth: 100% !important;\n}\n.text-center {\n\ttext-align: center !important;\n}\ntd[class=3Dremove] {\n\tdisplay: none !important;\n}\n}\n@media only screen and (max-width: 479px) {\nbody {\n\tfont-size: 10px !important;\n}\n.container {\n\twidth: 95% !important;\n\tpadding-left: 10px !important;\n\tpadding-right: 10px !important;\n}\n.mobiletext {\n\tfont-size: 16px !important;\n\tline-height: 20px !important;\n}\n.mobiletitle {\n\tfont-size: 22px !important;\n\tline-height: 26px !important;\n}\n.image-100-percent img {\n\twidth: 100% !important;\n\theight: auto !important;\n\tmax-width: 100% !important;\n\tmin-width: 124px !important;\n}\n.full-width {\n\twidth: 100% !important;\n}\n.text-center {\n\ttext-align: center !important;\n}\ntd[class=3Dremove] {\n\tdisplay: none !important;\n}\n}\n@media only screen and (min-width:768px) {\n.templateContainer {\n\twidth: 640px !important;\n}\n}\n</style>\n</head>\n<body style=3D\"padding:0;width:100%;background-color:#f8f8f8;-webkit-text=\n-size-adjust:100%;margin:0;-ms-text-size-adjust:100%;\">=0D\n<div style=3D\"color:transparent;visibility:hidden;opacity:0;font-size:0px=\n;border:0;max-height:1px;width:1px;margin:0px;padding:0px;border-width:0p=\nx!important;display:none!important;line-height:0px!important;\"><img borde=\nr=3D\"0\" width=3D\"1\" height=3D\"1\" src=3D\"https://td.perfect-quotes.com/q/1=\nmaAQDPyD3rwe0C16cifFA~~/AAAq-QA~/RgRi42u7PVcFc3BjZXVCCmEAu-YAYS4SbuBSD3Rl=\nYW1Ab3JpZW50by51a1gEAAAAAA~~\" style=3D\"text-decoration:none;-ms-interpola=\ntion-mode:bicubic;line-height:100%;outline:none;display:block !important;=\nborder:0;height:auto !important;\"></div>=0D\n\n<img src=3D\"https://cdn.perfect-quotes.com/px/YWE9MzQ3ODI4Njkmc2VpPTMwNjQ=\nwMyZ0az1BZW12Sk94T2ZQZ0FaekQyR3NONCZ0PTEmYz05MGFzODc2ZmQ4OWFzNWZnOGEwOXM=3D=\n\" style=3D\"top:-50px;text-decoration:none;-ms-interpolation-mode:bicubic;=\nline-height:100%;outline:none;display:block !important;position:absolute;=\nborder:0;height:auto !important;\" width=3D\"1\" height=3D\"1\" border=3D\"0\"> =\n<a href=3D'http://track.adamtest.viaduct.io/wpk1ye/RqY2c7vL' style=3D\"tex=\nt-decoration:none !important;mso-line-height-rule:exactly;-webkit-text-si=\nze-adjust:100%;-ms-text-size-adjust:100%;\"></a>\n<center>\n  <table align=3D\"center\" border=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0=\n\" width=3D\"100%\" id=3D\"bodyTable\" style=3D\"mso-table-lspace:0;border-spac=\ning:0;padding:0;width:100%;mso-table-rspace:0;-webkit-text-size-adjust:10=\n0%;border-collapse:collapse;margin:0;-ms-text-size-adjust:100%;\">\n    <tr>\n      <td align=3D\"center\" valign=3D\"top\" id=3D\"bodyCell\" style=3D\"mso-li=\nne-height-rule:exactly;padding:0;width:100%;-webkit-text-size-adjust:100%=\n;border-collapse:collapse;margin:0;-ms-text-size-adjust:100%;\"><!-- BEGIN=\n TEMPLATE // --> =\n\n        <!--[if (gte mso 9)|(IE)]>\n          <table align=3D\"center\" border=3D\"0\" cellspacing=3D\"0\" cellpadd=\ning=3D\"0\" width=3D\"100%\" style=3D\"width:640px;\">\n            <tr>\n              <td align=3D\"center\" valign=3D\"top\" width=3D\"100%\" style=3D=\n\"width:640px;\">\n                <![endif]-->\n        =\n\n        <table bgcolor=3D\"#ffffff\" border=3D\"0\" cellpadding=3D\"0\" cellspa=\ncing=3D\"0\" width=3D\"640\" class=3D\"full-width\" style=3D\"mso-table-lspace:0=\n;border-spacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;border=\n-collapse:collapse;-ms-text-size-adjust:100%;\">\n          <tr>\n            <td style=3D\"mso-line-height-rule:exactly;-webkit-text-size-a=\ndjust:100%;border-collapse:collapse;-ms-text-size-adjust:100%;\"><table bo=\nrder=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\" width=3D\"100%\" style=3D\"ms=\no-table-lspace:0;border-spacing:0;mso-table-rspace:0;-webkit-text-size-ad=\njust:100%;border-collapse:collapse;-ms-text-size-adjust:100%;\">\n                <tr>\n                  <td align=3D\"center\" bgcolor=3D\"#f8f8f8\" valign=3D\"top\"=\n class=3D\"fix-box\" style=3D\"mso-line-height-rule:exactly;-webkit-text-siz=\ne-adjust:100%;border-collapse:collapse;-ms-text-size-adjust:100%;\"><table=\n width=3D\"640\" align=3D\"center\" border=3D\"0\" cellspacing=3D\"0\" cellpaddin=\ng=3D\"0\" class=3D\"full-width\" style=3D\"mso-table-lspace:0;border-spacing:0=\n;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-collapse:collaps=\ne;-ms-text-size-adjust:100%;\">\n                      <tr>\n                        <td align=3D\"left\" style=3D\"word-break:break-word=\n;mso-line-height-rule:exactly;-webkit-text-size-adjust:100%;font-size:0px=\n;border-collapse:collapse;-ms-text-size-adjust:100%;\"><div style=3D\"font-=\nfamily:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;=\ntext-align:left;color:#000000;\">\n                            <div style=3D\"display:none !important; visibi=\nlity:hidden; mso-hide:all; font-size:1px; color:#ffffff; line-height:1px;=\n max-height:0px; max-width:0px; opacity:0;overflow:hidden;\">Invest in the=\n UK's best performing asset class</div>\n                            <div style=3D\"display: none; max-height: 0px;=\n overflow: hidden;\">=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=\n=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=\n=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=C2=\n=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=\n=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=\n=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0 =C2=A0=E2=80=8C=C2=A0=E2=\n=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=\n=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=\n=80=8C=C2=A0=E2=80=8C=C2=A0 =C2=A0=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=\n=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=\n=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0 =\n=C2=A0=C2=A0 =C2=A0=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=\n=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=\n=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0 =C2=A0=E2=80=8C=\n=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=\n=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=\n=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=\n=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=C2=A0=E2=80=8C=\n=C2=A0 =C2=A0</div>\n                          </div></td>\n                      </tr>\n                      <tr>\n                        <td align=3D\"center\" style=3D\"mso-line-height-rul=\ne:exactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text=\n-size-adjust:100%;\"><div style=3D\"font-family: arial, helvetica, sans-ser=\nif; color:#000000; text-align: center; font-size: 11px\"><div style=3D\"fon=\nt-family: arial, helvetica, sans-serif; color:#000000; text-align: center=\n; font-size: 11px\">\n<p style=3D\"mso-line-height-rule:exactly;padding:0;-webkit-text-size-adju=\nst:100%;margin:0;-ms-text-size-adjust:100%;\">ADs | Add <a style=3D\"text-d=\necoration:none !important;mso-line-height-rule:exactly;color:#000000;-web=\nkit-text-size-adjust:100%;-ms-text-size-adjust:100%;\" href=3D\"mailto:mail=\n@reply.perfect-quotes.com\">Perfect Quotes</a> to your address book | <a s=\ntyle=3D\"text-decoration:none !important;mso-line-height-rule:exactly;colo=\nr:#000000;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;\" href=3D=\n'http://track.adamtest.viaduct.io/wpk1ye/juFkxpIA' target=3D\"_blank\">Unsu=\nbscribe</a></p>\n</div></div></td>\n                      </tr>\n                    </table></td>\n                </tr>\n                <tr>\n                  <td align=3D\"center\" valign=3D\"top\" class=3D\"fix-box\" s=\ntyle=3D\"mso-line-height-rule:exactly;-webkit-text-size-adjust:100%;border=\n-collapse:collapse;-ms-text-size-adjust:100%;\"><table width=3D\"640\" bgcol=\nor=3D\"#262f37\" align=3D\"center\" border=3D\"0\" cellspacing=3D\"0\" cellpaddin=\ng=3D\"0\" class=3D\"full-width\" style=3D\"mso-table-lspace:0;border-spacing:0=\n;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-collapse:collaps=\ne;-ms-text-size-adjust:100%;\">\n                      <tbody>\n                        <tr>\n                          <td style=3D\"mso-line-height-rule:exactly;-webk=\nit-text-size-adjust:100%;border-collapse:collapse;-ms-text-size-adjust:10=\n0%;\"><table align=3D\"center\" border=3D\"0\" cellpadding=3D\"0\" cellspacing=3D=\n\"0\" class=3D\"full-width\" style=3D\"mso-table-lspace:0;border-spacing:0;mso=\n-table-rspace:0;-webkit-text-size-adjust:100%;border-collapse:collapse;-m=\ns-text-size-adjust:100%;\">\n                              <tbody>\n                                <tr>\n                                  <td width=3D\"100%\" align=3D\"center\" cla=\nss=3D\"image-100-percent\" style=3D\"mso-line-height-rule:exactly;-webkit-te=\nxt-size-adjust:100%;border-collapse:collapse;-ms-text-size-adjust:100%;\">=\n<a href=3D'http://track.adamtest.viaduct.io/wpk1ye/bqeFd39Y' style=3D\"tex=\nt-decoration:none !important;mso-line-height-rule:exactly;-webkit-text-si=\nze-adjust:100%;-ms-text-size-adjust:100%;\"> <img src=3D\"https://cdn.perfe=\nct-quotes.com/cdn/17806/banner.jpg\" style=3D\"text-decoration:none;-ms-int=\nerpolation-mode:bicubic;line-height:100%;outline:none;display:block !impo=\nrtant;border:0;height:auto;\" alt=3D\"The Villas\n\" class=3D\"image-100-percent\"> </a></td>\n                                </tr>\n                              </tbody>\n                            </table></td>\n                        </tr>\n                      </tbody>\n                    </table></td>\n                </tr>\n                <tr>\n                  <td align=3D\"center\" valign=3D\"top\" class=3D\"fix-box\" s=\ntyle=3D\"mso-line-height-rule:exactly;-webkit-text-size-adjust:100%;border=\n-collapse:collapse;-ms-text-size-adjust:100%;\"><table width=3D\"100%\" bgco=\nlor=3D\"#da2d27\" align=3D\"center\" border=3D\"0\" cellspacing=3D\"0\" cellpaddi=\nng=3D\"0\" class=3D\"full-width\" style=3D\"mso-table-lspace:0;border-spacing:=\n0;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-collapse:collap=\nse;-ms-text-size-adjust:100%;\">\n                      <tbody>\n                        <tr>\n                          <td width=3D\"15\" style=3D\"mso-line-height-rule:=\nexactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-s=\nize-adjust:100%;\"></td>\n                          <td style=3D\"mso-line-height-rule:exactly;paddi=\nng:5px 0px;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-tex=\nt-size-adjust:100%;\"><table align=3D\"center\" cellpadding=3D\"0\" cellspacin=\ng=3D\"0\" border=3D\"0\" style=3D\"mso-table-lspace:0;border-spacing:0;mso-tab=\nle-rspace:0;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-te=\nxt-size-adjust:100%;\" width=3D\"100%\">\n                              <tbody>\n                                <tr>\n                                  <td style=3D\"mso-line-height-rule:exact=\nly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-size-a=\ndjust:100%;\"><table width=3D\"610\" align=3D\"center\" class=3D\"full-width\" c=\nellpadding=3D\"0\" cellspacing=3D\"0\" border=3D\"0\" style=3D\"mso-table-lspace=\n:0;border-spacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;bord=\ner-collapse:collapse;-ms-text-size-adjust:100%;\">\n                                      <tr>\n                                        <td style=3D\"mso-line-height-rule=\n:exactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-=\nsize-adjust:100%;\"><table align=3D\"left\" border=3D\"0\" cellpadding=3D\"0\" c=\nellspacing=3D\"0\" class=3D\"full-width\" style=3D\"mso-table-lspace:0;border-=\nspacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-collaps=\ne:collapse;-ms-text-size-adjust:100%;\">\n                                            <tbody>\n                                              <tr>\n                                                <td style=3D\"mso-line-hei=\nght-rule:exactly;padding:0px 0px;-webkit-text-size-adjust:100%;border-col=\nlapse:collapse;-ms-text-size-adjust:100%;\"><table align=3D\"center\" cellpa=\ndding=3D\"0\" cellspacing=3D\"0\" border=3D\"0\" style=3D\"mso-table-lspace:0;bo=\nrder-spacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-co=\nllapse:collapse;-ms-text-size-adjust:100%;\" class=3D\"full-width\">\n                                                    <tr>\n                                                      <td valign=3D\"middl=\ne\" width=3D\"100%\" align=3D\"center\" style=3D\"font-family:'Poppins', Arial,=\n Helvetica, sans-serif;line-height:35px;mso-line-height-rule:exactly;padd=\ning:3px 5px  0px;color:#ffffff;text-align:left;text-transform:uppercase;-=\nwebkit-text-size-adjust:100%;font-weight:900;font-size:30px;border-collap=\nse:collapse;-ms-text-size-adjust:100%;\"> Phase 2 Now Released! </td>\n                                                    </tr>\n                                                  </table></td>\n                                              </tr>\n                                            </tbody>\n                                          </table></td>\n                                      </tr>\n                                    </table></td>\n                                </tr>\n                              </tbody>\n                            </table></td>\n                          <td width=3D\"15\" style=3D\"mso-line-height-rule:=\nexactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-s=\nize-adjust:100%;\"></td>\n                        </tr>\n                      </tbody>\n                    </table></td>\n                </tr>\n                <tr>\n                  <td align=3D\"center\" valign=3D\"top\" class=3D\"fix-box\" s=\ntyle=3D\"mso-line-height-rule:exactly;-webkit-text-size-adjust:100%;border=\n-collapse:collapse;-ms-text-size-adjust:100%;\"><table width=3D\"100%\" bgco=\nlor=3D\"#2c2c2c\" align=3D\"center\" border=3D\"0\" cellspacing=3D\"0\" cellpaddi=\nng=3D\"0\" class=3D\"full-width\" style=3D\"mso-table-lspace:0;border-spacing:=\n0;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-collapse:collap=\nse;-ms-text-size-adjust:100%;\">\n                      <tbody>\n                        <tr>\n                          <td width=3D\"15\" style=3D\"mso-line-height-rule:=\nexactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-s=\nize-adjust:100%;\"></td>\n                          <td style=3D\"mso-line-height-rule:exactly;paddi=\nng:5px 0px;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-tex=\nt-size-adjust:100%;\"><table align=3D\"center\" cellpadding=3D\"0\" cellspacin=\ng=3D\"0\" border=3D\"0\" style=3D\"mso-table-lspace:0;border-spacing:0;mso-tab=\nle-rspace:0;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-te=\nxt-size-adjust:100%;\" width=3D\"100%\">\n                              <tbody>\n                                <tr>\n                                  <td style=3D\"mso-line-height-rule:exact=\nly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-size-a=\ndjust:100%;\"><table width=3D\"610\" align=3D\"center\" class=3D\"full-width\" c=\nellpadding=3D\"0\" cellspacing=3D\"0\" border=3D\"0\" style=3D\"mso-table-lspace=\n:0;border-spacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;bord=\ner-collapse:collapse;-ms-text-size-adjust:100%;\">\n                                      <tr>\n                                        <td style=3D\"mso-line-height-rule=\n:exactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-=\nsize-adjust:100%;\"><table align=3D\"left\" border=3D\"0\" cellpadding=3D\"0\" c=\nellspacing=3D\"0\" class=3D\"full-width\" style=3D\"mso-table-lspace:0;border-=\nspacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-collaps=\ne:collapse;-ms-text-size-adjust:100%;\">\n                                            <tbody>\n                                              <tr>\n                                                <td style=3D\"mso-line-hei=\nght-rule:exactly;padding:0px 0px;-webkit-text-size-adjust:100%;border-col=\nlapse:collapse;-ms-text-size-adjust:100%;\"><table align=3D\"center\" cellpa=\ndding=3D\"0\" cellspacing=3D\"0\" border=3D\"0\" style=3D\"mso-table-lspace:0;bo=\nrder-spacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-co=\nllapse:collapse;-ms-text-size-adjust:100%;\" class=3D\"full-width\">\n                                                    <tr>\n                                                      <td valign=3D\"middl=\ne\" width=3D\"100%\" align=3D\"center\" style=3D\"font-family:'Poppins', Arial,=\n Helvetica, sans-serif;line-height:30px;mso-line-height-rule:exactly;padd=\ning:6px 5px;color:#ffffff;text-align:left;text-transform:uppercase;-webki=\nt-text-size-adjust:100%;font-weight:600;font-size:22px;border-collapse:co=\nllapse;-ms-text-size-adjust:100%;\"> 8.5% NET Returns Guaranteed For 3 Yea=\nrs </td>\n                                                    </tr>\n                                                  </table></td>\n                                              </tr>\n                                            </tbody>\n                                          </table></td>\n                                      </tr>\n                                    </table></td>\n                                </tr>\n                              </tbody>\n                            </table></td>\n                          <td width=3D\"15\" style=3D\"mso-line-height-rule:=\nexactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-s=\nize-adjust:100%;\"></td>\n                        </tr>\n                      </tbody>\n                    </table></td>\n                </tr>\n                <tr>\n                  <td align=3D\"center\" valign=3D\"top\" class=3D\"fix-box\" s=\ntyle=3D\"mso-line-height-rule:exactly;-webkit-text-size-adjust:100%;border=\n-collapse:collapse;-ms-text-size-adjust:100%;\"><table width=3D\"640\" bgcol=\nor=3D\"#ffffff\" align=3D\"center\" border=3D\"0\" cellspacing=3D\"0\" cellpaddin=\ng=3D\"0\" class=3D\"full-width\" style=3D\"mso-table-lspace:0;border-spacing:0=\n;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-collapse:collaps=\ne;-ms-text-size-adjust:100%;\">\n                      <tr>\n                        <td width=3D\"25\" style=3D\"mso-line-height-rule:ex=\nactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-siz=\ne-adjust:100%;\"></td>\n                        <td style=3D\"mso-line-height-rule:exactly;-webkit=\n-text-size-adjust:100%;border-collapse:collapse;-ms-text-size-adjust:100%=\n;\"><table class=3D\"full-width\" border=3D\"0\" cellspacing=3D\"0\" width=3D\"10=\n0%\" cellpadding=3D\"0\" align=3D\"center\" style=3D\"mso-table-lspace:0;border=\n-spacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-collap=\nse:collapse;-ms-text-size-adjust:100%;\">\n                            <tr>\n                              <td valign=3D\"top\" style=3D\"mso-line-height=\n-rule:exactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-=\ntext-size-adjust:100%;\"><table align=3D\"center\" cellpadding=3D\"0\" cellspa=\ncing=3D\"0\" border=3D\"0\" style=3D\"mso-table-lspace:0;border-spacing:0;mso-=\ntable-rspace:0;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms=\n-text-size-adjust:100%;\" class=3D\"full-width\">\n                                  <tr>\n                                    <td width=3D\"100%\" align=3D\"center\" s=\ntyle=3D\"font-family:'Poppins', Arial, Helvetica, sans-serif;mso-line-heig=\nht-rule:exactly;line-height:24px;padding:25px 10px 15px;color:#000000;tex=\nt-align:center;-webkit-text-size-adjust:100%;font-weight:400;font-size:16=\npx;border-collapse:collapse;-ms-text-size-adjust:100%;\">The Villas is a b=\nrand-new investment opportunity in the student accommodation sector, idea=\nlly located in Stoke-on-Trent to meet the huge demand from the 25,000+ st=\nudents at the city=E2=80=99s Keele and Staffordshire Universities. The 17=\n4 studio apartments offer spacious, contemporary interiors, with developm=\nent facilities including communal study and relaxation spaces, caf=C3=A9,=\n gym, bike storage and car parks. Units start from a low =C2=A374,950, wi=\nth furniture included within the price and 8.5% NET returns guaranteed fo=\nr the first 3 years. </td>\n                                  </tr>\n                                </table></td>\n                            </tr>\n                          </table></td>\n                        <td width=3D\"25\" style=3D\"mso-line-height-rule:ex=\nactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-siz=\ne-adjust:100%;\"></td>\n                      </tr>\n                    </table></td>\n                </tr>\n                <tr>\n                  <td align=3D\"center\" valign=3D\"top\" style=3D\"mso-line-h=\neight-rule:exactly;padding:10px 0px 10px;-webkit-text-size-adjust:100%;bo=\nrder-collapse:collapse;-ms-text-size-adjust:100%;\" class=3D\"fix-box\"><tab=\nle bgcolor=3D\"#ffffff\" width=3D\"640\" align=3D\"center\" border=3D\"0\" cellsp=\nacing=3D\"0\" cellpadding=3D\"0\" class=3D\"full-width\" style=3D\"mso-table-lsp=\nace:0;border-spacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;b=\norder-collapse:collapse;-ms-text-size-adjust:100%;\">\n                      <tr>\n                        <td width=3D\"30\" style=3D\"mso-line-height-rule:ex=\nactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-siz=\ne-adjust:100%;\"></td>\n                        <td bgcolor=3D\"#da2d27\" style=3D\"mso-line-height-=\nrule:exactly;padding:0px;-webkit-text-size-adjust:100%;border-collapse:co=\nllapse;-ms-text-size-adjust:100%;\"><table bgcolor=3D\"#da2d27\" class=3D\"fu=\nll-width\" border=3D\"0\" cellspacing=3D\"0\" cellpadding=3D\"0\" align=3D\"cente=\nr\" style=3D\"mso-table-lspace:0;border-spacing:0;mso-table-rspace:0;-webki=\nt-text-size-adjust:100%;border-collapse:collapse;-ms-text-size-adjust:100=\n%;\">\n                            <tr>\n                              <td style=3D\"mso-line-height-rule:exactly;p=\nadding:5px 10px;-webkit-text-size-adjust:100%;border-collapse:collapse;-m=\ns-text-size-adjust:100%;\" align=3D\"right\" valign=3D\"top\"><a href=3D'http:=\n//track.adamtest.viaduct.io/wpk1ye/kWwMAIGR' style=3D\"text-decoration:non=\ne !important;mso-line-height-rule:exactly;-webkit-text-size-adjust:100%;-=\nms-text-size-adjust:100%;\"><img width=3D\"106\" src=3D\"https://cdn.perfect-=\nquotes.com/cdn/17806/percentlogo.png\" style=3D\"text-decoration:none;-ms-i=\nnterpolation-mode:bicubic;max-width:106px;line-height:100%;outline:none;d=\nisplay:block !important;border:0;height:auto;\"></a></td>\n                              <td width=3D\"100%\" align=3D\"center\" style=3D=\n\"font-family:'Poppins', Arial, Helvetica, sans-serif;line-height:28px;mso=\n-line-height-rule:exactly;color:#ffffff;text-align:centre;padding:6px 5px=\n 4px 5px;text-transform:uppercase;-webkit-text-size-adjust:100%;font-weig=\nht:500;font-size:22px;border-collapse:collapse;-ms-text-size-adjust:100%;=\n\">The Villas is a Cash-Only Investor Opportunity From Birchmore </td>\n                            </tr>\n                          </table></td>\n                        <td width=3D\"30\" style=3D\"mso-line-height-rule:ex=\nactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-siz=\ne-adjust:100%;\"></td>\n                      </tr>\n                    </table></td>\n                </tr>\n                <tr>\n                  <td align=3D\"center\" valign=3D\"top\" style=3D\"mso-line-h=\neight-rule:exactly;padding:20px 0px 10px;-webkit-text-size-adjust:100%;bo=\nrder-collapse:collapse;-ms-text-size-adjust:100%;\" class=3D\"fix-box\"><tab=\nle bgcolor=3D\"#ffffff\" width=3D\"640\" align=3D\"center\" border=3D\"0\" cellsp=\nacing=3D\"0\" cellpadding=3D\"0\" class=3D\"full-width\" style=3D\"mso-table-lsp=\nace:0;border-spacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;b=\norder-collapse:collapse;-ms-text-size-adjust:100%;\">\n                      <tr>\n                        <td width=3D\"30\" style=3D\"mso-line-height-rule:ex=\nactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-siz=\ne-adjust:100%;\"></td>\n                        <td style=3D\"mso-line-height-rule:exactly;padding=\n:0px;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-size=\n-adjust:100%;\"><table style=3D\"mso-table-lspace:0;border-spacing:0;border=\n-bottom:25px solid #ffffff;mso-table-rspace:0;-webkit-text-size-adjust:10=\n0%;border-collapse:collapse;-ms-text-size-adjust:100%;\" bgcolor=3D\"#f6f6f=\n6\" class=3D\"full-width\" border=3D\"0\" cellspacing=3D\"0\" width=3D\"275\" cell=\npadding=3D\"0\" align=3D\"left\">\n                            <tr>\n                              <td colspan=3D\"3\" align=3D\"center\" valign=3D=\n\"top\" style=3D\"mso-line-height-rule:exactly;-webkit-text-size-adjust:100%=\n;border-collapse:collapse;-ms-text-size-adjust:100%;\"><a href=3D'http://t=\nrack.adamtest.viaduct.io/wpk1ye/NrOxRevH' style=3D\"text-decoration:none !=\nimportant;mso-line-height-rule:exactly;-webkit-text-size-adjust:100%;-ms-=\ntext-size-adjust:100%;\"><img width=3D\"120\" src=3D\"https://cdn.perfect-quo=\ntes.com/cdn/17806/icon1.png\" style=3D\"text-decoration:none;-ms-interpolat=\nion-mode:bicubic;max-width:120px;line-height:100%;outline:none;display:bl=\nock !important;border:0;height:auto;\"></a></td>\n                            </tr>\n                            <tr>\n                              <td valign=3D\"top\" style=3D\"mso-line-height=\n-rule:exactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-=\ntext-size-adjust:100%;\"><table width=3D\"100%\" align=3D\"center\" cellpaddin=\ng=3D\"0\" cellspacing=3D\"0\" border=3D\"0\" style=3D\"mso-table-lspace:0;border=\n-spacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-collap=\nse:collapse;-ms-text-size-adjust:100%;\" class=3D\"full-width\">\n                                  <tr>\n                                    <td width=3D\"100%\" align=3D\"center\" s=\ntyle=3D\"font-family:'Poppins', Arial, Helvetica, sans-serif;line-height:2=\n4px;mso-line-height-rule:exactly;padding:5px 5px 0px;color:#2c2c2c;text-a=\nlign:center;text-transform:uppercase;-webkit-text-size-adjust:100%;font-w=\neight:600;font-size:20px;border-collapse:collapse;-ms-text-size-adjust:10=\n0%;\">City-Centre Location </td>\n                                  </tr>\n                                  <tr>\n                                    <td width=3D\"100%\" align=3D\"center\" s=\ntyle=3D\"font-family:'Poppins', Arial, Helvetica, sans-serif;line-height:2=\n0px;mso-line-height-rule:exactly;color:#2c2c2c;padding:5px 10px  15px;tex=\nt-align:center;border-bottom:5px solid #2c2c2c;-webkit-text-size-adjust:1=\n00%;font-weight:400;font-size:15px;border-collapse:collapse;-ms-text-size=\n-adjust:100%;\"> Perfectly situated in the<br style=3D\"line-height:100%;\">=\n\n                                      centre of Stoke-on-Trent,<br style=3D=\n\"line-height:100%;\">\n                                      ensuring maximum appeal to<br style=\n=3D\"line-height:100%;\">\n                                      the city=E2=80=99s student populati=\non </td>\n                                  </tr>\n                                </table></td>\n                            </tr>\n                          </table>\n                          <table style=3D\"mso-table-lspace:0;border-spaci=\nng:0;border-bottom:25px solid #ffffff;mso-table-rspace:0;-webkit-text-siz=\ne-adjust:100%;border-collapse:collapse;-ms-text-size-adjust:100%;\" bgcolo=\nr=3D\"#f6f6f6\" class=3D\"full-width\" border=3D\"0\" cellspacing=3D\"0\" width=3D=\n\"275\" cellpadding=3D\"0\" align=3D\"right\">\n                            <tr>\n                              <td colspan=3D\"3\" align=3D\"center\" valign=3D=\n\"top\" style=3D\"mso-line-height-rule:exactly;-webkit-text-size-adjust:100%=\n;border-collapse:collapse;-ms-text-size-adjust:100%;\"><a href=3D'http://t=\nrack.adamtest.viaduct.io/wpk1ye/Kah6eGaJ' style=3D\"text-decoration:none !=\nimportant;mso-line-height-rule:exactly;-webkit-text-size-adjust:100%;-ms-=\ntext-size-adjust:100%;\"><img width=3D\"120\" src=3D\"https://cdn.perfect-quo=\ntes.com/cdn/17806/icon4.png\" style=3D\"text-decoration:none;-ms-interpolat=\nion-mode:bicubic;max-width:120px;line-height:100%;outline:none;display:bl=\nock !important;border:0;height:auto;\"></a></td>\n                            </tr>\n                            <tr>\n                              <td valign=3D\"top\" style=3D\"mso-line-height=\n-rule:exactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-=\ntext-size-adjust:100%;\"><table align=3D\"center\" cellpadding=3D\"0\" cellspa=\ncing=3D\"0\" border=3D\"0\" style=3D\"mso-table-lspace:0;border-spacing:0;mso-=\ntable-rspace:0;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms=\n-text-size-adjust:100%;\" class=3D\"full-width\">\n                                  <tr>\n                                    <td width=3D\"100%\" align=3D\"center\" s=\ntyle=3D\"font-family:'Poppins', Arial, Helvetica, sans-serif;mso-line-heig=\nht-rule:exactly;line-height:24px;color:#2c2c2c;text-align:center;padding:=\n5px 5px 0px;text-transform:uppercase;-webkit-text-size-adjust:100%;font-w=\neight:600;font-size:20px;border-collapse:collapse;-ms-text-size-adjust:10=\n0%;\">Hands-Off Investment </td>\n                                  </tr>\n                                  <tr>\n                                    <td width=3D\"100%\" align=3D\"center\" s=\ntyle=3D\"font-family:'Poppins', Arial, Helvetica, sans-serif;line-height:2=\n0px;mso-line-height-rule:exactly;color:#2c2c2c;padding:5px 10px  15px;tex=\nt-align:center;border-bottom:5px solid #2c2c2c;-webkit-text-size-adjust:1=\n00%;font-weight:400;font-size:15px;border-collapse:collapse;-ms-text-size=\n-adjust:100%;\"> Fully managed by Homes<br style=3D\"line-height:100%;\">\n                                      For Students, one of the UK=E2=80=99=\ns<br style=3D\"line-height:100%;\">\n                                      leading providers of quality<br sty=\nle=3D\"line-height:100%;\">\n                                      student accommodation </td>\n                                  </tr>\n                                </table></td>\n                            </tr>\n                          </table></td>\n                        <td width=3D\"30\" style=3D\"mso-line-height-rule:ex=\nactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-siz=\ne-adjust:100%;\"></td>\n                      </tr>\n                      <tr>\n                        <td width=3D\"30\" style=3D\"mso-line-height-rule:ex=\nactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-siz=\ne-adjust:100%;\"></td>\n                        <td style=3D\"mso-line-height-rule:exactly;padding=\n:0px;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-size=\n-adjust:100%;\"><table style=3D\"mso-table-lspace:0;border-spacing:0;border=\n-bottom:10px solid #ffffff;mso-table-rspace:0;-webkit-text-size-adjust:10=\n0%;border-collapse:collapse;-ms-text-size-adjust:100%;\" bgcolor=3D\"#f6f6f=\n6\" class=3D\"full-width\" border=3D\"0\" cellspacing=3D\"0\" width=3D\"275\" cell=\npadding=3D\"0\" align=3D\"left\">\n                            <tr>\n                              <td colspan=3D\"3\" align=3D\"center\" valign=3D=\n\"top\" style=3D\"mso-line-height-rule:exactly;-webkit-text-size-adjust:100%=\n;border-collapse:collapse;-ms-text-size-adjust:100%;\"><a href=3D'http://t=\nrack.adamtest.viaduct.io/wpk1ye/alRllU9d' style=3D\"text-decoration:none !=\nimportant;mso-line-height-rule:exactly;-webkit-text-size-adjust:100%;-ms-=\ntext-size-adjust:100%;\"><img width=3D\"120\" src=3D\"https://cdn.perfect-quo=\ntes.com/cdn/17806/icon3.png\" style=3D\"text-decoration:none;-ms-interpolat=\nion-mode:bicubic;max-width:120px;line-height:100%;outline:none;display:bl=\nock !important;border:0;height:auto;\"></a></td>\n                            </tr>\n                            <tr>\n                              <td valign=3D\"top\" style=3D\"mso-line-height=\n-rule:exactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-=\ntext-size-adjust:100%;\"><table width=3D\"100%\" align=3D\"center\" cellpaddin=\ng=3D\"0\" cellspacing=3D\"0\" border=3D\"0\" style=3D\"mso-table-lspace:0;border=\n-spacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-collap=\nse:collapse;-ms-text-size-adjust:100%;\" class=3D\"full-width\">\n                                  <tr>\n                                    <td width=3D\"100%\" align=3D\"center\" s=\ntyle=3D\"font-family:'Poppins', Arial, Helvetica, sans-serif;line-height:2=\n4px;mso-line-height-rule:exactly;color:#2c2c2c;text-align:center;padding:=\n5px 5px 0px;text-transform:uppercase;-webkit-text-size-adjust:100%;font-w=\neight:600;font-size:20px;border-collapse:collapse;-ms-text-size-adjust:10=\n0%;\">FURNITURE INCLUDED </td>\n                                  </tr>\n                                  <tr>\n                                    <td width=3D\"100%\" align=3D\"center\" s=\ntyle=3D\"font-family:'Poppins', Arial, Helvetica, sans-serif;line-height:2=\n0px;mso-line-height-rule:exactly;text-align:center;color:#2c2c2c;padding:=\n5px 10px  15px;border-bottom:5px solid #2c2c2c;-webkit-text-size-adjust:1=\n00%;font-weight:400;font-size:15px;border-collapse:collapse;-ms-text-size=\n-adjust:100%;\"> Full furniture packs included<br style=3D\"line-height:100=\n%;\">\n                                      in the purchase price,<br style=3D\"=\nline-height:100%;\">\n                                      providing both value and<br style=3D=\n\"line-height:100%;\">\n                                      convenience for investors </td>\n                                  </tr>\n                                </table></td>\n                            </tr>\n                          </table>\n                          <table style=3D\"mso-table-lspace:0;border-spaci=\nng:0;border-bottom:10px solid #ffffff;mso-table-rspace:0;-webkit-text-siz=\ne-adjust:100%;border-collapse:collapse;-ms-text-size-adjust:100%;\" bgcolo=\nr=3D\"#f6f6f6\" class=3D\"full-width\" border=3D\"0\" cellspacing=3D\"0\" width=3D=\n\"275\" cellpadding=3D\"0\" align=3D\"right\">\n                            <tr>\n                              <td colspan=3D\"3\" align=3D\"center\" valign=3D=\n\"top\" style=3D\"mso-line-height-rule:exactly;-webkit-text-size-adjust:100%=\n;border-collapse:collapse;-ms-text-size-adjust:100%;\"><a href=3D'http://t=\nrack.adamtest.viaduct.io/wpk1ye/Az715kpB' style=3D\"text-decoration:none !=\nimportant;mso-line-height-rule:exactly;-webkit-text-size-adjust:100%;-ms-=\ntext-size-adjust:100%;\"><img width=3D\"120\" src=3D\"https://cdn.perfect-quo=\ntes.com/cdn/17806/icon2.png\" style=3D\"text-decoration:none;-ms-interpolat=\nion-mode:bicubic;max-width:120px;line-height:100%;outline:none;display:bl=\nock !important;border:0;height:auto;\"></a></td>\n                            </tr>\n                            <tr>\n                              <td valign=3D\"top\" style=3D\"mso-line-height=\n-rule:exactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-=\ntext-size-adjust:100%;\"><table width=3D\"100%\" align=3D\"center\" cellpaddin=\ng=3D\"0\" cellspacing=3D\"0\" border=3D\"0\" style=3D\"mso-table-lspace:0;border=\n-spacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-collap=\nse:collapse;-ms-text-size-adjust:100%;\" class=3D\"full-width\">\n                                  <tr>\n                                    <td width=3D\"100%\" align=3D\"center\" s=\ntyle=3D\"font-family:'Poppins', Arial, Helvetica, sans-serif;mso-line-heig=\nht-rule:exactly;line-height:24px;text-align:center;padding:5px 5px 0px;co=\nlor:#2c2c2c;text-transform:uppercase;-webkit-text-size-adjust:100%;font-w=\neight:600;font-size:20px;border-collapse:collapse;-ms-text-size-adjust:10=\n0%;\">4% Paid On Deposits </td>\n                                  </tr>\n                                  <tr>\n                                    <td width=3D\"100%\" align=3D\"center\" s=\ntyle=3D\"font-family:'Poppins', Arial, Helvetica, sans-serif;line-height:2=\n0px;mso-line-height-rule:exactly;padding:5px 10px  15px;text-align:center=\n;color:#2c2c2c;border-bottom:5px solid #2c2c2c;-webkit-text-size-adjust:1=\n00%;font-weight:400;font-size:15px;border-collapse:collapse;-ms-text-size=\n-adjust:100%;\"> Interest paid on reservation<br style=3D\"line-height:100%=\n;\">\n                                      deposits from exchange until<br sty=\nle=3D\"line-height:100%;\">\n                                      the development completes<br style=3D=\n\"line-height:100%;\">\n                                      in September 2022 </td>\n                                  </tr>\n                                </table></td>\n                            </tr>\n                          </table></td>\n                        <td width=3D\"30\" style=3D\"mso-line-height-rule:ex=\nactly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-siz=\ne-adjust:100%;\"></td>\n                      </tr>\n                    </table></td>\n                </tr>\n                <tr>\n                  <td align=3D\"center\" valign=3D\"top\" class=3D\"fix-box\" s=\ntyle=3D\"mso-line-height-rule:exactly;padding-top:15px;-webkit-text-size-a=\ndjust:100%;border-collapse:collapse;-ms-text-size-adjust:100%;\"><table wi=\ndth=3D\"100%\" bgcolor=3D\"#2c2c2c\" align=3D\"center\" border=3D\"0\" cellspacin=\ng=3D\"0\" cellpadding=3D\"0\" class=3D\"full-width\" style=3D\"mso-table-lspace:=\n0;border-spacing:0;border-top:40px solid #1f2738;border-bottom:40px solid=\n #1f2738;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-collapse=\n:collapse;-ms-text-size-adjust:100%;\">\n                      <tbody>\n                        <tr>\n                          <td bgcolor=3D\"#da2d27\" width=3D\"15\" style=3D\"m=\nso-line-height-rule:exactly;-webkit-text-size-adjust:100%;border-collapse=\n:collapse;-ms-text-size-adjust:100%;\"></td>\n                          <td bgcolor=3D\"#da2d27\" style=3D\"mso-line-heigh=\nt-rule:exactly;padding:10px 0px;-webkit-text-size-adjust:100%;border-coll=\napse:collapse;-ms-text-size-adjust:100%;\"><table align=3D\"center\" cellpad=\nding=3D\"0\" cellspacing=3D\"0\" border=3D\"0\" style=3D\"mso-table-lspace:0;bor=\nder-spacing:0;mso-table-rspace:0;-webkit-text-size-adjust:100%;border-col=\nlapse:collapse;-ms-text-size-adjust:100%;\" width=3D\"100%\">\n                              <tbody>\n                                <tr>\n                                  <td style=3D\"mso-line-height-rule:exact=\nly;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-size-a=\ndjust:100%;\"><table align=3D\"center\" cellpadding=3D\"0\" cellspacing=3D\"0\" =\nborder=3D\"0\" style=3D\"mso-table-lspace:0;border-spacing:0;mso-table-rspac=\ne:0;-webkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-size-=\nadjust:100%;\" class=3D\"full-width\">\n                                      <tr>\n                                        <td valign=3D\"middle\" width=3D\"10=\n0%\" align=3D\"center\" style=3D\"font-family:'Poppins', Arial, Helvetica, sa=\nns-serif;line-height:40px;mso-line-height-rule:exactly;text-align:center;=\ncolor:#ffffff;padding:5px 5px  4px;text-transform:uppercase;-webkit-text-=\nsize-adjust:100%;font-weight:900;font-size:32px;border-collapse:collapse;=\n-ms-text-size-adjust:100%;\"><a style=3D\"text-decoration:none;mso-line-hei=\nght-rule:exactly;color:#ffffff;-webkit-text-size-adjust:100%;-ms-text-siz=\ne-adjust:100%;\" href=3D'http://track.adamtest.viaduct.io/wpk1ye/x9ladWLR'=\n>REQUEST YOUR BROCHURE</a></td>\n                                      </tr>\n                                    </table></td>\n                                </tr>\n                              </tbody>\n                            </table></td>\n                          <td width=3D\"15\" bgcolor=3D\"#da2d27\" style=3D\"m=\nso-line-height-rule:exactly;-webkit-text-size-adjust:100%;border-collapse=\n:collapse;-ms-text-size-adjust:100%;\"></td>\n                        </tr>\n                      </tbody>\n                    </table></td>\n                </tr>\n              </table></td>\n          </tr>\n        </table>\n        =\n\n        <!--[if (gte mso 9)|(IE)]>\n          </td>\n        </tr>\n      </table>\n      <![endif]--> =\n\n        =\n\n        <!-- // END TEMPLATE --> =\n\n        =\n\n        <!--[if (gte mso 9)|(IE)]>\n          <table align=3D\"center\" border=3D\"0\" cellspacing=3D\"0\" cellpadd=\ning=3D\"0\" width=3D\"100%\" style=3D\"width:640px;\">\n            <tr>\n              <td align=3D\"center\" valign=3D\"top\" width=3D\"100%\" style=3D=\n\"width:640px;\">\n                <![endif]-->\n        =\n\n        <table border=3D\"0\" cellpadding=3D\"0\" cellspacing=3D\"0\" width=3D\"=\n640\" class=3D\"full-width\" style=3D\"mso-table-lspace:0;border-spacing:0;ms=\no-table-rspace:0;-webkit-text-size-adjust:100%;border-collapse:collapse;-=\nms-text-size-adjust:100%;\">\n          <tr>\n            <td align=3D\"center\" style=3D\"mso-line-height-rule:exactly;-w=\nebkit-text-size-adjust:100%;border-collapse:collapse;-ms-text-size-adjust=\n:100%;\"><div style=3D\"font-family:Arial, Helvetica, sans-serif; font-size=\n:10px; color:#000000; line-height:20px; text-align: center\"><tr><td align=\n=3D\"center\" style=3D\"mso-line-height-rule:exactly;-webkit-text-size-adjus=\nt:100%;border-collapse:collapse;-ms-text-size-adjust:100%;\">=0D\n<div style=3D\"font-family:Arial, Helvetica, sans-serif; font-size:10px; c=\nolor:#000000; line-height:20px; text-align: center\">=0D\n<span><br style=3D\"line-height:100%;\">You are receiving message 34782869 =\non your email address from DataSoftSolutions LP, 64 Cumberland Street, Ed=\ninburgh, UK<br style=3D\"line-height:100%;\">=0D\nbecause you are on Time Travel Promotion LP's list of managers and profes=\nsionals under controller ID AemvJOxOfPgAZzD2GsN4.<br style=3D\"line-height=\n:100%;\">=0D\nYou have the right of access, rectification, opposition and consent for y=\nour data which you can access under <a href=3D'http://track.adamtest.viad=\nuct.io/wpk1ye/ivU9lF3O' style=3D\"text-decoration:underline;mso-line-heigh=\nt-rule:exactly;color:#000000;-webkit-text-size-adjust:100%;-ms-text-size-=\nadjust:100%;\">Privacy policy.</a><br style=3D\"line-height:100%;\">=0D\nTo stop receiving Special Offers by email for your professional activity<=\nbr style=3D\"line-height:100%;\">=0D\nor if you wish to personalize your experience follow the data controller =\n<a href=3D'http://track.adamtest.viaduct.io/wpk1ye/So0hAySC' style=3D\"tex=\nt-decoration:underline;mso-line-height-rule:exactly;color:#000000;-webkit=\n-text-size-adjust:100%;-ms-text-size-adjust:100%;\">page</a>.=0D\n</span></div>=0D\n</td></tr></div></td>\n          </tr>\n        </table>\n        =\n\n        <!--[if (gte mso 9)|(IE)]>\n          </td>\n        </tr>\n      </table>\n      <![endif]--></td>\n    </tr>\n  </table>\n</center>\n<img src=3D\"https://cdn.perfect-quotes.com/timg/aHR0cHM6Ly9hZGxlYWRybmV0d=\n29yay5jb20vaS5hc2h4P2E9MTAmYz0xNTgzJnMxPVNVQl9JRA=3D=3D\" width=3D\"1\" heig=\nht=3D\"1\" border=3D\"0\" style=3D\"text-decoration:none;-ms-interpolation-mod=\ne:bicubic;line-height:100%;outline:none;display:block !important;border:0=\n;height:auto !important;\">\n=0D\n<img border=3D\"0\" width=3D\"1\" height=3D\"1\" alt=3D\"\" src=3D\"https://td.per=\nfect-quotes.com/q/dUoNiFL_C9xh7FaMg4YN0w~~/AAAq-QA~/RgRi42u7PlcFc3BjZXVCC=\nmEAu-YAYS4SbuBSD3RlYW1Ab3JpZW50by51a1gEAAAAAA~~\" style=3D\"text-decoration=\n:none;-ms-interpolation-mode:bicubic;line-height:100%;outline:none;displa=\ny:block !important;border:0;height:auto !important;\">=0D\n<p class=3D'ampimg' style=3D'display:none;visibility:none;margin:0;paddin=\ng:0;line-height:0;'><img src=3D'http://track.adamtest.viaduct.io/img/wpk1=\nye/YzFrlleYgzEO' alt=3D''></p></body>\n</html>=0D\n=0D\n"
  },
  {
    "path": "spec/examples/full_legacy_config_file.yml",
    "content": "# This a legacy configuration file format which was used in Postal version\n# less than v3. It remains supported in v3+ by mapping these values to their\n# correct values. Support for this file format will be removed in Postal v4.\n#\n# It exists here for reference but also to faciliate testing to ensure the\n# legacy mapping works as expected\nversion: 1\n\nweb:\n  host: postal.llamas.com\n  protocol: https\n\ngeneral:\n  use_ip_pools: false\n  exception_url: https://sentry.llamas.com/abcdef1234\n  maximum_delivery_attempts: 20\n  maximum_hold_expiry_days: 10\n  suppression_list_removal_delay: 60\n  use_local_ns_for_domains: true\n  default_spam_threshold: 10\n  default_spam_failure_threshold: 25\n  use_resent_sender_header: true\n\nweb_server:\n  bind_address: 127.0.0.1\n  port: 6000\n  max_threads: 10\n\nmain_db:\n  host: localhost\n  port: 3306\n  username: postal\n  password: t35tpassword\n  database: postal\n  pool_size: 20\n  encoding: utf8mb4\n\nmessage_db:\n  host: localhost\n  port: 3306\n  username: postal\n  password: p05t41\n  prefix: postal\n\nlogging:\n  rails_log: true\n  graylog:\n    host: logs.llamas.com\n    port: 12201\n    facility: mailer\n\nsmtp_server:\n  port: 25\n  bind_address: 127.0.0.1\n  tls_enabled: true\n  tls_certificate_path: config/smtp.cert\n  tls_private_key_path: config/smtp.key\n  tls_ciphers: abc\n  ssl_version: SSLv23\n  proxy_protocol: false\n  log_connect: true\n  max_message_size: 10\n\nsmtp_relays:\n  - hostname: 1.2.3.4\n    port: 25\n    ssl_mode: Auto\n  - hostname: 2.2.2.2\n    port: 2525\n    ssl_mode: None\n\ndns:\n  mx_records:\n    - mx1.postal.llamas.com\n    - mx2.postal.llamas.com\n  smtp_server_hostname: smtp.postal.llamas.com\n  spf_include: spf.postal.llamas.com\n  return_path: rp.postal.llamas.com\n  route_domain: routes.postal.llamas.com\n  track_domain: track.postal.llamas.com\n  helo_hostname: helo.postal.llamas.com\n  dkim_identifier: postal\n  domain_verify_prefix: postal-verification\n  custom_return_path_prefix: psrp\n\nsmtp:\n  host: 127.0.0.1\n  port: 25\n  username: postalserver\n  password: llama\n  from_name: Postal\n  from_address: postal@llamas.com\n\nrails:\n  environment: production\n  secret_key: abcdef123123123123123\n\nrspamd:\n  enabled: true\n  host: rspamd.llamas.com\n  port: 11334\n  ssl: false\n  password: llama\n  flags: abc\n\nspamd:\n  enabled: false\n  host: spamd.llamas.com\n  port: 783\n\nclamav:\n  enabled: false\n  host: clamav.llamas.com\n  port: 2000\n\nsmtp_client:\n  open_timeout: 60\n  read_timeout: 120\n"
  },
  {
    "path": "spec/factories/address_endpoint_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: address_endpoints\n#\n#  id           :integer          not null, primary key\n#  address      :string(255)\n#  last_used_at :datetime\n#  uuid         :string(255)\n#  created_at   :datetime         not null\n#  updated_at   :datetime         not null\n#  server_id    :integer\n#\nFactoryBot.define do\n  factory :address_endpoint do\n    server\n    sequence(:address) { |n| \"test#{n}@example.com\" }\n  end\nend\n"
  },
  {
    "path": "spec/factories/credential_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: credentials\n#\n#  id           :integer          not null, primary key\n#  hold         :boolean          default(FALSE)\n#  key          :string(255)\n#  last_used_at :datetime\n#  name         :string(255)\n#  options      :text(65535)\n#  type         :string(255)\n#  uuid         :string(255)\n#  created_at   :datetime\n#  updated_at   :datetime\n#  server_id    :integer\n#\nFactoryBot.define do\n  factory :credential do\n    server\n    name { \"Example Credential\" }\n    type { \"API\" }\n  end\nend\n"
  },
  {
    "path": "spec/factories/domain_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: domains\n#\n#  id                     :integer          not null, primary key\n#  server_id              :integer\n#  uuid                   :string(255)\n#  name                   :string(255)\n#  verification_token     :string(255)\n#  verification_method    :string(255)\n#  verified_at            :datetime\n#  dkim_private_key       :text(65535)\n#  created_at             :datetime\n#  updated_at             :datetime\n#  dns_checked_at         :datetime\n#  spf_status             :string(255)\n#  spf_error              :string(255)\n#  dkim_status            :string(255)\n#  dkim_error             :string(255)\n#  mx_status              :string(255)\n#  mx_error               :string(255)\n#  return_path_status     :string(255)\n#  return_path_error      :string(255)\n#  outgoing               :boolean          default(TRUE)\n#  incoming               :boolean          default(TRUE)\n#  owner_type             :string(255)\n#  owner_id               :integer\n#  dkim_identifier_string :string(255)\n#  use_for_any            :boolean\n#\n# Indexes\n#\n#  index_domains_on_server_id  (server_id)\n#  index_domains_on_uuid       (uuid)\n#\n\nFactoryBot.define do\n  factory :domain do\n    association :owner, factory: :organization\n    sequence(:name) { |n| \"example#{n}.com\" }\n    verification_method { \"DNS\" }\n    verified_at { Time.now }\n\n    trait :unverified do\n      verified_at { nil }\n    end\n\n    trait :dns_all_ok do\n      spf_status { \"OK\" }\n      dkim_status { \"OK\" }\n      mx_status { \"OK\" }\n      return_path_status { \"OK\" }\n    end\n  end\n\n  factory :organization_domain, parent: :domain do\n    association :owner, factory: :organization\n  end\nend\n"
  },
  {
    "path": "spec/factories/http_endpoint_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: http_endpoints\n#\n#  id                  :integer          not null, primary key\n#  disabled_until      :datetime\n#  encoding            :string(255)\n#  error               :text(65535)\n#  format              :string(255)\n#  include_attachments :boolean          default(TRUE)\n#  last_used_at        :datetime\n#  name                :string(255)\n#  strip_replies       :boolean          default(FALSE)\n#  timeout             :integer\n#  url                 :string(255)\n#  uuid                :string(255)\n#  created_at          :datetime\n#  updated_at          :datetime\n#  server_id           :integer\n#\nFactoryBot.define do\n  factory :http_endpoint do\n    server\n    name { \"HTTP endpoint\" }\n    url { \"https://example.com/endpoint\" }\n    encoding { \"BodyAsJSON\" }\n    format { \"Hash\" }\n  end\nend\n"
  },
  {
    "path": "spec/factories/ip_address_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: ip_addresses\n#\n#  id         :integer          not null, primary key\n#  hostname   :string(255)\n#  ipv4       :string(255)\n#  ipv6       :string(255)\n#  priority   :integer\n#  created_at :datetime\n#  updated_at :datetime\n#  ip_pool_id :integer\n#\nFactoryBot.define do\n  factory :ip_address do\n    ip_pool\n    ipv4 { \"10.0.0.1\" }\n    ipv6 { \"2001:0db8:85a3:0000:0000:8a2e:0370:7334\" }\n    hostname { \"ip.example.com\" }\n  end\nend\n"
  },
  {
    "path": "spec/factories/ip_pool_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: ip_pools\n#\n#  id         :integer          not null, primary key\n#  default    :boolean          default(FALSE)\n#  name       :string(255)\n#  uuid       :string(255)\n#  created_at :datetime\n#  updated_at :datetime\n#\n# Indexes\n#\n#  index_ip_pools_on_uuid  (uuid)\n#\nFactoryBot.define do\n  factory :ip_pool do\n    name { \"Default Pool\" }\n    default { true }\n\n    trait :with_ip_address do\n      after(:create) do |ip_pool|\n        ip_pool.ip_addresses << create(:ip_address, ip_pool: ip_pool)\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/factories/ip_pool_rule_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: ip_pool_rules\n#\n#  id         :integer          not null, primary key\n#  from_text  :text(65535)\n#  owner_type :string(255)\n#  to_text    :text(65535)\n#  uuid       :string(255)\n#  created_at :datetime         not null\n#  updated_at :datetime         not null\n#  ip_pool_id :integer\n#  owner_id   :integer\n#\nFactoryBot.define do\n  factory :ip_pool_rule do\n    owner factory: :organization\n    ip_pool\n    to_text { \"google.com\" }\n\n    after(:build) do |ip_pool_rule|\n      if ip_pool_rule.ip_pool.organizations.empty? && ip_pool_rule.owner.is_a?(Organization)\n        ip_pool_rule.ip_pool.organizations << ip_pool_rule.owner\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/factories/organization_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: organizations\n#\n#  id                :integer          not null, primary key\n#  uuid              :string(255)\n#  name              :string(255)\n#  permalink         :string(255)\n#  time_zone         :string(255)\n#  created_at        :datetime\n#  updated_at        :datetime\n#  ip_pool_id        :integer\n#  owner_id          :integer\n#  deleted_at        :datetime\n#  suspended_at      :datetime\n#  suspension_reason :string(255)\n#\n# Indexes\n#\n#  index_organizations_on_permalink  (permalink)\n#  index_organizations_on_uuid       (uuid)\n#\n\nFactoryBot.define do\n  factory :organization do\n    name { \"Acme Inc\" }\n    sequence(:permalink) { |n| \"org#{n}\" }\n    association :owner, factory: :user\n\n    trait :suspended do\n      suspended_at { 1.day.ago }\n      suspension_reason { \"test\" }\n    end\n  end\nend\n"
  },
  {
    "path": "spec/factories/queued_message_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: queued_messages\n#\n#  id            :integer          not null, primary key\n#  attempts      :integer          default(0)\n#  batch_key     :string(255)\n#  domain        :string(255)\n#  locked_at     :datetime\n#  locked_by     :string(255)\n#  manual        :boolean          default(FALSE)\n#  retry_after   :datetime\n#  created_at    :datetime\n#  updated_at    :datetime\n#  ip_address_id :integer\n#  message_id    :integer\n#  route_id      :integer\n#  server_id     :integer\n#\n# Indexes\n#\n#  index_queued_messages_on_domain      (domain)\n#  index_queued_messages_on_message_id  (message_id)\n#  index_queued_messages_on_server_id   (server_id)\n#\nFactoryBot.define do\n  factory :queued_message do\n    domain { \"example.com\" }\n\n    transient do\n      message { nil }\n    end\n\n    after(:build) do |message, evaluator|\n      if evaluator.message\n        message.server = evaluator.message.server\n        message.message_id = evaluator.message.id\n        message.batch_key = evaluator.message.batch_key\n        message.domain = evaluator.message.recipient_domain\n        message.route_id = evaluator.message.route_id\n      else\n        message.server ||= create(:server)\n        message.message_id ||= 0\n      end\n    end\n\n    trait :locked do\n      locked_by { \"worker1\" }\n      locked_at { 5.minutes.ago }\n    end\n\n    trait :retry_in_future do\n      attempts { 2 }\n      retry_after { 1.hour.from_now }\n    end\n  end\nend\n"
  },
  {
    "path": "spec/factories/route_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: routes\n#\n#  id            :integer          not null, primary key\n#  endpoint_type :string(255)\n#  mode          :string(255)\n#  name          :string(255)\n#  spam_mode     :string(255)\n#  token         :string(255)\n#  uuid          :string(255)\n#  created_at    :datetime\n#  updated_at    :datetime\n#  domain_id     :integer\n#  endpoint_id   :integer\n#  server_id     :integer\n#\n# Indexes\n#\n#  index_routes_on_token  (token)\n#\nFactoryBot.define do\n  factory :route do\n    name { \"test\" }\n    mode { \"Accept\" }\n    spam_mode { \"Mark\" }\n\n    before(:create) do |route|\n      route.server ||= create(:server)\n      route.domain ||= create(:domain, owner: route.server)\n    end\n  end\nend\n"
  },
  {
    "path": "spec/factories/server_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: servers\n#\n#  id                                 :integer          not null, primary key\n#  allow_sender                       :boolean          default(FALSE)\n#  deleted_at                         :datetime\n#  domains_not_to_click_track         :text(65535)\n#  log_smtp_data                      :boolean          default(FALSE)\n#  message_retention_days             :integer\n#  mode                               :string(255)\n#  name                               :string(255)\n#  outbound_spam_threshold            :decimal(8, 2)\n#  permalink                          :string(255)\n#  postmaster_address                 :string(255)\n#  privacy_mode                       :boolean          default(FALSE)\n#  raw_message_retention_days         :integer\n#  raw_message_retention_size         :integer\n#  send_limit                         :integer\n#  send_limit_approaching_at          :datetime\n#  send_limit_approaching_notified_at :datetime\n#  send_limit_exceeded_at             :datetime\n#  send_limit_exceeded_notified_at    :datetime\n#  spam_failure_threshold             :decimal(8, 2)\n#  spam_threshold                     :decimal(8, 2)\n#  suspended_at                       :datetime\n#  suspension_reason                  :string(255)\n#  token                              :string(255)\n#  uuid                               :string(255)\n#  created_at                         :datetime\n#  updated_at                         :datetime\n#  ip_pool_id                         :integer\n#  organization_id                    :integer\n#\n# Indexes\n#\n#  index_servers_on_organization_id  (organization_id)\n#  index_servers_on_permalink        (permalink)\n#  index_servers_on_token            (token)\n#  index_servers_on_uuid             (uuid)\n#\n\nFactoryBot.define do\n  factory :server do\n    association :organization\n    name { \"Mail Server\" }\n    mode { \"Live\" }\n    provision_database { false }\n    sequence(:permalink) { |n| \"server#{n}\" }\n\n    trait :suspended do\n      suspended_at { Time.current }\n      suspension_reason { \"Test Reason\" }\n    end\n\n    trait :exceeded_send_limit do\n      send_limit_approaching_at { 5.minutes.ago }\n      send_limit_exceeded_at { 1.minute.ago }\n    end\n  end\nend\n"
  },
  {
    "path": "spec/factories/smtp_endpoint_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: smtp_endpoints\n#\n#  id             :integer          not null, primary key\n#  disabled_until :datetime\n#  error          :text(65535)\n#  hostname       :string(255)\n#  last_used_at   :datetime\n#  name           :string(255)\n#  port           :integer\n#  ssl_mode       :string(255)\n#  uuid           :string(255)\n#  created_at     :datetime\n#  updated_at     :datetime\n#  server_id      :integer\n#\nFactoryBot.define do\n  factory :smtp_endpoint do\n    server\n    name { \"Example SMTP Endpoint\" }\n    hostname { \"example.com\" }\n    ssl_mode { \"None\" }\n    port { 25 }\n  end\nend\n"
  },
  {
    "path": "spec/factories/track_domain_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: track_domains\n#\n#  id                     :integer          not null, primary key\n#  uuid                   :string(255)\n#  server_id              :integer\n#  domain_id              :integer\n#  name                   :string(255)\n#  dns_checked_at         :datetime\n#  dns_status             :string(255)\n#  dns_error              :string(255)\n#  created_at             :datetime         not null\n#  updated_at             :datetime         not null\n#  ssl_enabled            :boolean          default(TRUE)\n#  track_clicks           :boolean          default(TRUE)\n#  track_loads            :boolean          default(TRUE)\n#  excluded_click_domains :text(65535)\n#\n\nFactoryBot.define do\n  factory :track_domain do\n    name { \"click\" }\n    dns_status { \"OK\" }\n    association :server\n\n    after(:build) do |track_domain|\n      track_domain.domain ||= create(:domain, owner: track_domain.server)\n    end\n  end\nend\n"
  },
  {
    "path": "spec/factories/user_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: users\n#\n#  id                               :integer          not null, primary key\n#  admin                            :boolean          default(FALSE)\n#  email_address                    :string(255)\n#  email_verification_token         :string(255)\n#  email_verified_at                :datetime\n#  first_name                       :string(255)\n#  last_name                        :string(255)\n#  oidc_issuer                      :string(255)\n#  oidc_uid                         :string(255)\n#  password_digest                  :string(255)\n#  password_reset_token             :string(255)\n#  password_reset_token_valid_until :datetime\n#  time_zone                        :string(255)\n#  uuid                             :string(255)\n#  created_at                       :datetime\n#  updated_at                       :datetime\n#\n# Indexes\n#\n#  index_users_on_email_address  (email_address)\n#  index_users_on_uuid           (uuid)\n#\n\nFactoryBot.define do\n  factory :user do\n    first_name { \"John\" }\n    last_name { \"Doe\" }\n    password { \"passw0rd\" }\n    email_verified_at { Time.now }\n    sequence(:email_address) { |n| \"user#{n}@example.com\" }\n  end\nend\n"
  },
  {
    "path": "spec/factories/webhook_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: webhooks\n#\n#  id           :integer          not null, primary key\n#  all_events   :boolean          default(FALSE)\n#  enabled      :boolean          default(TRUE)\n#  last_used_at :datetime\n#  name         :string(255)\n#  sign         :boolean          default(TRUE)\n#  url          :string(255)\n#  uuid         :string(255)\n#  created_at   :datetime\n#  updated_at   :datetime\n#  server_id    :integer\n#\n# Indexes\n#\n#  index_webhooks_on_server_id  (server_id)\n#\nFactoryBot.define do\n  factory :webhook do\n    server\n    name { \"Example Webhook\" }\n    url { \"https://example.com\" }\n    all_events { true }\n  end\nend\n"
  },
  {
    "path": "spec/factories/webhook_request_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: webhook_requests\n#\n#  id          :integer          not null, primary key\n#  attempts    :integer          default(0)\n#  error       :text(65535)\n#  event       :string(255)\n#  locked_at   :datetime\n#  locked_by   :string(255)\n#  payload     :text(65535)\n#  retry_after :datetime\n#  url         :string(255)\n#  uuid        :string(255)\n#  created_at  :datetime\n#  server_id   :integer\n#  webhook_id  :integer\n#\n# Indexes\n#\n#  index_webhook_requests_on_locked_by  (locked_by)\n#\nFactoryBot.define do\n  factory :webhook_request do\n    webhook\n    url { \"https://example.com\" }\n    event { \"ExampleEvent\" }\n    payload { { \"hello\" => \"world\" } }\n\n    before(:create) do |webhook_request|\n      webhook_request.server = webhook_request.webhook&.server\n    end\n\n    trait :locked do\n      locked_by { \"test\" }\n      locked_at { 5.minutes.ago }\n    end\n  end\nend\n"
  },
  {
    "path": "spec/factories/worker_role_factory.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: worker_roles\n#\n#  id          :bigint           not null, primary key\n#  acquired_at :datetime\n#  role        :string(255)\n#  worker      :string(255)\n#\n# Indexes\n#\n#  index_worker_roles_on_role  (role) UNIQUE\n#\nFactoryBot.define do\n  factory :worker_role do\n    role { \"test\" }\n  end\nend\n"
  },
  {
    "path": "spec/helpers/general_helpers.rb",
    "content": "# frozen_string_literal: true\n\nmodule GeneralHelpers\n\n  def create_plain_text_message(server, text, to = \"test@example.com\", override_attributes = {})\n    domain = create(:domain, owner: server)\n    attributes = { from: \"test@#{domain.name}\", subject: \"Test Plain Text Message\" }.merge(override_attributes)\n    attributes[:to] = to\n    attributes[:plain_body] = text\n    message = OutgoingMessagePrototype.new(server, \"127.0.0.1\", \"testsuite\", attributes)\n    result = message.create_message(to)\n    server.message_db.message(result[:id])\n  end\n\nend\n"
  },
  {
    "path": "spec/helpers/message_db_mocking.rb",
    "content": "# frozen_string_literal: true\n\nmodule GlobalMessageDB\n\n  class << self\n\n    def find_or_create\n      return @db if @db\n\n      @db = Postal::MessageDB::Database.new(1, 1, database_name: \"postal-test-message-db\")\n      @db.provisioner.provision\n    end\n\n    def exists?\n      !@db.nil?\n    end\n\n  end\n\nend\n\nRSpec.configure do |config|\n  config.before(:example) do\n    @mocked_message_dbs = []\n    allow_any_instance_of(Server).to receive(:message_db).and_wrap_original do |m|\n      GlobalMessageDB.find_or_create\n\n      message_db = m.call\n      @mocked_message_dbs << message_db\n      allow(message_db).to receive(:database_name).and_return(\"postal-test-message-db\")\n      message_db\n    end\n  end\n\n  config.after(:example) do\n    if GlobalMessageDB.exists? && @mocked_message_dbs.present?\n      GlobalMessageDB.find_or_create.provisioner.clean\n      @mocked_message_dbs = []\n    end\n  end\nend\n"
  },
  {
    "path": "spec/helpers/message_factory.rb",
    "content": "# frozen_string_literal: true\n\n# This class can be used to generate a message which can be used for the purposes of\n# testing within the given server.\nclass MessageFactory\n\n  def initialize(server)\n    @server = server\n  end\n\n  def incoming(route: nil, &block)\n    @message = @server.message_db.new_message\n    @message.scope = \"incoming\"\n    @message.rcpt_to = \"test@example.com\"\n    @message.mail_from = \"john@example.com\"\n\n    if route\n      @message.rcpt_to = route.description\n      @message.route_id = route.id\n    end\n\n    create_message(&block)\n  end\n\n  def outgoing(domain: nil, credential: nil, &block)\n    @message = @server.message_db.new_message\n    @message.scope = \"outgoing\"\n    @message.rcpt_to = \"john@example.com\"\n    @message.mail_from = \"test@example.com\"\n\n    if domain\n      @message.mail_from = \"test@#{domain.name}\"\n      @message.domain_id = domain.id\n    end\n\n    if credential\n      @message.credential_id = credential.id\n    end\n\n    create_message(&block)\n  end\n\n  class << self\n\n    def incoming(server, **kwargs, &block)\n      new(server).incoming(**kwargs, &block)\n    end\n\n    def outgoing(server, **kwargs, &block)\n      new(server).outgoing(**kwargs, &block)\n    end\n\n  end\n\n  private\n\n  def create_message\n    mail = create_mail(@message.rcpt_to, @message.mail_from)\n\n    if block_given?\n      yield @message, mail\n    end\n\n    @message.raw_message = mail.to_s\n    @message.save(queue_on_create: false)\n    @message\n  end\n\n  def create_mail(to, from)\n    mail = Mail.new\n    mail.to = to\n    mail.from = from\n    mail.subject = \"An example message\"\n    mail.body = \"Hello world!\"\n    mail\n  end\n\nend\n"
  },
  {
    "path": "spec/helpers/test_logger.rb",
    "content": "# frozen_string_literal: true\n\nclass TestLogger\n\n  def initialize\n    @log_lines = []\n    @group_set = Klogger::GroupSet.new\n    @print = false\n  end\n\n  def print!\n    @print = true\n  end\n\n  def add(level, message, **tags)\n    @group_set.groups.each do |group|\n      tags = group[:tags].merge(tags)\n    end\n\n    @log_lines << { level: level, message: message, tags: tags }\n    puts message if @print\n    true\n  end\n\n  [:info, :debug, :warn, :error].each do |level|\n    define_method(level) do |message, **tags|\n      add(level, message, **tags)\n    end\n  end\n\n  def tagged(**tags, &block)\n    @group_set.call_without_id(**tags, &block)\n  end\n\n  def log_line(match)\n    @log_lines.reverse.each do |log_line|\n      return log_line if match.is_a?(String) && log_line[:message] == match\n      return log_line if match.is_a?(Regexp) && log_line[:message] =~ match\n    end\n    nil\n  end\n\n  def has_logged?(match)\n    !!log_line(match)\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/dkim_header_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\ndescribe DKIMHeader do\n  examples = Rails.root.join(\"spec/examples/dkim_signing/*.msg\")\n  Dir[examples].each do |path|\n    contents = File.read(path)\n    frontmatter, email = contents.split(/^---\\n/m, 2)\n    frontmatter = YAML.safe_load(frontmatter)\n    email.strip\n    it \"works with #{path.split('/').last}\" do\n      mocked_time = Time.at(frontmatter[\"time\"].to_i)\n      allow(Time).to receive(:now).and_return(mocked_time)\n\n      domain = instance_double(\"Domain\")\n      allow(domain).to receive(:dkim_status).and_return(\"OK\")\n      allow(domain).to receive(:name).and_return(frontmatter[\"domain\"])\n      allow(domain).to receive(:dkim_key).and_return(OpenSSL::PKey::RSA.new(frontmatter[\"private_key\"]))\n      allow(domain).to receive(:dkim_identifier).and_return(frontmatter[\"dkim_identifier\"])\n\n      expectation = \"DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\\r\\n\" \\\n                    \"\\td=#{frontmatter['domain']};\\r\\n\" \\\n                    \"\\ts=#{frontmatter['dkim_identifier']}; t=#{mocked_time.to_i};\\r\\n\" \\\n                    \"\\tbh=#{frontmatter['bh']};\\r\\n\" \\\n                    \"\\th=#{frontmatter['headers']};\\r\\n\" \\\n                    \"\\tb=#{frontmatter['b'].scan(/.{1,72}/).join(\"\\r\\n\\t\")}\"\n\n      header = described_class.new(domain, email)\n\n      expect(header.dkim_header).to eq expectation\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/dns_resolver_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nRSpec.describe DNSResolver do\n  subject(:resolver) { described_class.local }\n\n  # Now, we could mock everything in here which would give us some comfort\n  # but I do think that we'll benefit more from having a full E2E test here\n  # so we'll test this using values which we know to be fairly static and\n  # that are within our control.\n\n  describe \"#a\" do\n    it \"returns a list of IP addresses\" do\n      expect(resolver.a(\"www.dnstest.postalserver.io\").sort).to eq [\"1.2.3.4\", \"2.3.4.5\"]\n    end\n\n    it \"resolves a domain name containing an emoji\" do\n      expect(resolver.a(\"☺.dnstest.postalserver.io\").sort).to eq [\"3.4.5.6\"]\n    end\n\n    it \"returns an empty array when timeout is exceeded\" do\n      allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n      expect(resolver.a(\"www.dnstest.postalserver.io\")).to eq []\n    end\n\n    context \"when raise_timeout_errors is true\" do\n      it \"returns a list of IP addresses\" do\n        expect(resolver.a(\"www.dnstest.postalserver.io\", raise_timeout_errors: true).sort).to eq [\"1.2.3.4\", \"2.3.4.5\"]\n      end\n\n      it \"raises an error when the timeout is exceeded\" do\n        allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n        expect do\n          resolver.a(\"www.dnstest.postalserver.io\", raise_timeout_errors: true)\n        end.to raise_error(Resolv::ResolvError, /timeout/)\n      end\n    end\n  end\n\n  describe \"#aaaa\" do\n    it \"returns a list of IP addresses\" do\n      expect(resolver.aaaa(\"www.dnstest.postalserver.io\").sort).to eq [\"2a00:67a0:a::1\", \"2a00:67a0:a::2\"]\n    end\n\n    it \"returns an empty array when timeout is exceeded\" do\n      allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n      expect(resolver.aaaa(\"www.dnstest.postalserver.io\")).to eq []\n    end\n\n    context \"when raise_timeout_errors is true\" do\n      it \"returns a list of IP addresses\" do\n        expect(resolver.aaaa(\"www.dnstest.postalserver.io\", raise_timeout_errors: true).sort).to eq [\"2a00:67a0:a::1\", \"2a00:67a0:a::2\"]\n      end\n\n      it \"raises an error when the timeout is exceeded\" do\n        allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n        expect do\n          resolver.aaaa(\"www.dnstest.postalserver.io\", raise_timeout_errors: true)\n        end.to raise_error(Resolv::ResolvError, /timeout/)\n      end\n    end\n  end\n\n  describe \"#txt\" do\n    it \"returns a list of TXT records\" do\n      expect(resolver.txt(\"dnstest.postalserver.io\").sort).to eq [\n        \"an example txt record\",\n        \"another example\",\n      ]\n    end\n\n    it \"returns an empty array when timeout is exceeded\" do\n      allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n      expect(resolver.txt(\"dnstest.postalserver.io\")).to eq []\n    end\n\n    context \"when raise_timeout_errors is true\" do\n      it \"returns a list of TXT records\" do\n        expect(resolver.txt(\"dnstest.postalserver.io\", raise_timeout_errors: true).sort).to eq [\n          \"an example txt record\",\n          \"another example\",\n        ]\n      end\n\n      it \"raises an error when the timeout is exceeded\" do\n        allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n        expect do\n          resolver.txt(\"dnstest.postalserver.io\", raise_timeout_errors: true)\n        end.to raise_error(Resolv::ResolvError, /timeout/)\n      end\n    end\n  end\n\n  describe \"#cname\" do\n    it \"returns a list of CNAME records\" do\n      expect(resolver.cname(\"cname.dnstest.postalserver.io\")).to eq [\"www.dnstest.postalserver.io\"]\n    end\n\n    it \"returns an empty array when timeout is exceeded\" do\n      allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n      expect(resolver.cname(\"cname.dnstest.postalserver.io\")).to eq []\n    end\n\n    context \"when raise_timeout_errors is true\" do\n      it \"returns a list of CNAME records\" do\n        expect(resolver.cname(\"cname.dnstest.postalserver.io\", raise_timeout_errors: true)).to eq [\"www.dnstest.postalserver.io\"]\n      end\n\n      it \"raises an error when the timeout is exceeded\" do\n        allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n        expect do\n          resolver.cname(\"cname.dnstest.postalserver.io\", raise_timeout_errors: true)\n        end.to raise_error(Resolv::ResolvError, /timeout/)\n      end\n    end\n  end\n\n  describe \"#mx\" do\n    it \"returns a list of MX records\" do\n      expect(resolver.mx(\"dnstest.postalserver.io\")).to eq [\n        [10, \"mx1.dnstest.postalserver.io\"],\n        [20, \"mx2.dnstest.postalserver.io\"],\n      ]\n    end\n\n    it \"returns an empty array when timeout is exceeded\" do\n      allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n      expect(resolver.mx(\"dnstest.postalserver.io\")).to eq []\n    end\n\n    context \"when raise_timeout_errors is true\" do\n      it \"returns a list of MX records\" do\n        expect(resolver.mx(\"dnstest.postalserver.io\", raise_timeout_errors: true)).to eq [\n          [10, \"mx1.dnstest.postalserver.io\"],\n          [20, \"mx2.dnstest.postalserver.io\"],\n        ]\n      end\n\n      it \"raises an error when the timeout is exceeded\" do\n        allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n        expect do\n          resolver.mx(\"dnstest.postalserver.io\", raise_timeout_errors: true)\n        end.to raise_error(Resolv::ResolvError, /timeout/)\n      end\n    end\n  end\n\n  describe \"#effective_ns\" do\n    it \"returns the nameserver names that are authoritative for the given domain\" do\n      expect(resolver.effective_ns(\"postalserver.io\").sort).to eq [\n        \"prestigious-honeybadger.katapultdns.com\",\n        \"the-cake-is-a-lie.katapultdns.com\",\n      ]\n    end\n\n    it \"returns an empty array when timeout is exceeded\" do\n      allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n      expect(resolver.effective_ns(\"postalserver.io\")).to eq []\n    end\n\n    context \"when raise_timeout_errors is true\" do\n      it \"returns a list of NS records\" do\n        expect(resolver.effective_ns(\"postalserver.io\", raise_timeout_errors: true).sort).to eq [\n          \"prestigious-honeybadger.katapultdns.com\",\n          \"the-cake-is-a-lie.katapultdns.com\",\n        ]\n      end\n\n      it \"raises an error when the timeout is exceeded\" do\n        allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n        expect do\n          resolver.effective_ns(\"postalserver.io\", raise_timeout_errors: true)\n        end.to raise_error(Resolv::ResolvError, /timeout/)\n      end\n    end\n  end\n\n  describe \"#ip_to_hostname\" do\n    it \"returns the hostname for the given IP\" do\n      expect(resolver.ip_to_hostname(\"151.252.1.100\")).to eq \"ns1.katapultdns.com\"\n    end\n\n    it \"returns the IP when the timeout is exceeded\" do\n      allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n      expect(resolver.ip_to_hostname(\"151.252.1.100\")).to eq \"151.252.1.100\"\n    end\n\n    context \"when raise_timeout_errors is true\" do\n      it \"returns the hostname for the given IP\" do\n        expect(resolver.ip_to_hostname(\"151.252.1.100\", raise_timeout_errors: true)).to eq \"ns1.katapultdns.com\"\n      end\n\n      it \"raises an error when the timeout is exceeded\" do\n        allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)\n        expect do\n          resolver.ip_to_hostname(\"151.252.1.100\", raise_timeout_errors: true)\n        end.to raise_error(Resolv::ResolvError, /timeout/)\n      end\n    end\n  end\n\n  describe \".for_domain\" do\n    it \"finds the effective nameservers for a given domain and returns them\" do\n      resolver = described_class.for_domain(\"dnstest.postalserver.io\")\n      expect(resolver.nameservers.sort).to eq [\"151.252.1.100\", \"151.252.2.100\"]\n    end\n  end\n\n  describe \".local\" do\n    after do\n      # Remove all cached values for the local resolver\n      DNSResolver.instance_variable_set(:@local, nil)\n    end\n\n    it \"returns a resolver with the local machine's resolvers\" do\n      resolver = described_class.local\n      expect(resolver.nameservers).to be_a Array\n      expect(resolver.nameservers).to_not be_empty\n    end\n\n    context \"when there is no resolv.conf\" do\n      it \"raises an error\" do\n        allow(File).to receive(:file?).with(\"/etc/resolv.conf\").and_return(false)\n        expect { described_class.local }.to raise_error(DNSResolver::LocalResolversUnavailableError,\n                                                        /no resolver config found at/i)\n      end\n    end\n\n    context \"when no nameservers are found in resolv.conf\" do\n      it \"raises an error\" do\n        allow(Resolv::DNS::Config).to receive(:parse_resolv_conf).with(\"/etc/resolv.conf\").and_return({})\n        expect { described_class.local }.to raise_error(DNSResolver::LocalResolversUnavailableError,\n                                                        /could not find nameservers in/i)\n      end\n    end\n\n    context \"when an empty array of nameserver is found in resolv.conf\" do\n      it \"raises an error\" do\n        allow(Resolv::DNS::Config).to receive(:parse_resolv_conf).with(\"/etc/resolv.conf\")\n                                                                 .and_return({ nameserver: [] })\n        expect { described_class.local }.to raise_error(DNSResolver::LocalResolversUnavailableError,\n                                                        /could not find nameservers in/i)\n      end\n    end\n  end\n\n  context \"when using a resolver for a domain\" do\n    subject(:resolver) { described_class.for_domain(\"dnstest.postalserver.io\") }\n\n    it \"will not return domains that are not hosted on that server\" do\n      expect(resolver.a(\"example.com\")).to eq []\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/message_dequeuer/base_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule MessageDequeuer\n\n  RSpec.describe Base do\n    describe \".new\" do\n      context \"when given state\" do\n        it \"uses that state\" do\n          base = described_class.new(nil, logger: nil, state: 1234)\n          expect(base.state).to eq 1234\n        end\n      end\n\n      context \"when not given state\" do\n        it \"creates a new state\" do\n          base = described_class.new(nil, logger: nil)\n          expect(base.state).to be_a State\n        end\n      end\n    end\n\n    describe \".process\" do\n      it \"creates a new instances of the class and calls process\" do\n        message = create(:queued_message)\n        logger = TestLogger.new\n\n        mock = double(\"Base\")\n        expect(mock).to receive(:process).once\n        expect(described_class).to receive(:new).with(message, logger: logger).and_return(mock)\n\n        described_class.process(message, logger: logger)\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/message_dequeuer/incoming_message_processor_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule MessageDequeuer\n\n  RSpec.describe IncomingMessageProcessor do\n    let(:server) { create(:server) }\n    let(:state) { State.new }\n    let(:logger) { TestLogger.new }\n    let(:route) { create(:route, server: server) }\n    let(:message) { MessageFactory.incoming(server, route: route) }\n    let(:queued_message) { create(:queued_message, :locked, message: message) }\n\n    subject(:processor) { described_class.new(queued_message, logger: logger, state: state) }\n\n    context \"when the message was a bounce but there's no return path for it\" do\n      let(:message) do\n        MessageFactory.incoming(server) do |msg|\n          msg.bounce = true\n        end\n      end\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/no source messages found, hard failing/)\n      end\n\n      it \"sets the message status to HardFail\" do\n        processor.process\n        expect(message.reload.status).to eq \"HardFail\"\n      end\n\n      it \"creates a HardFail delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"HardFail\", details: /was a bounce but we couldn't link it with any outgoing message/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the message is a bounce for an existing message\" do\n      let(:existing_message) { MessageFactory.outgoing(server) }\n\n      let(:message) do\n        MessageFactory.incoming(server) do |msg, mail|\n          msg.bounce = true\n          mail[\"X-Postal-MsgID\"] = existing_message.token\n        end\n      end\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/message is a bounce/)\n      end\n\n      it \"adds the original message as the bounce ID for the received message\" do\n        processor.process\n        expect(message.reload.bounce_for_id).to eq existing_message.id\n      end\n\n      it \"sets the received message status to Processed\" do\n        processor.process\n        expect(message.reload.status).to eq \"Processed\"\n      end\n\n      it \"creates a Processed delivery on the received message\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"Processed\", details: /This has been detected as a bounce message for <msg:#{existing_message.id}>/i)\n      end\n\n      it \"sets the existing message status to Bounced\" do\n        processor.process\n        expect(existing_message.reload.status).to eq \"Bounced\"\n      end\n\n      it \"creates a Bounced delivery on the original message\" do\n        processor.process\n        delivery = existing_message.deliveries.last\n        expect(delivery).to have_attributes(status: \"Bounced\", details: /received a bounce message for this e-mail. See <msg:#{message.id}> for/i)\n      end\n\n      it \"triggers a MessageBounced webhook event\" do\n        expect(WebhookRequest).to receive(:trigger).with(server, \"MessageBounced\", {\n          original_message: kind_of(Hash),\n          bounce: kind_of(Hash)\n        })\n        processor.process\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the message is not a bounce\" do\n      it \"increments the stats for the server\" do\n        expect { processor.process }.to change { server.message_db.live_stats.total(5) }.by(1)\n      end\n\n      it \"inspects the message and adds headers\" do\n        expect { processor.process }.to change { message.reload.inspected }.from(false).to(true)\n        new_message = message.reload\n        expect(new_message.headers).to match hash_including(\n          \"x-postal-spam\" => [\"no\"],\n          \"x-postal-spam-threshold\" => [\"5.0\"],\n          \"x-postal-threat\" => [\"no\"]\n        )\n      end\n\n      it \"marks the message as spam if the spam score is higher than the server threshold\" do\n        inspection_result = double(\"Result\", spam_score: server.spam_threshold + 1, threat: false, threat_message: nil, spam_checks: [])\n        allow(Postal::MessageInspection).to receive(:scan).and_return(inspection_result)\n        processor.process\n        expect(message.reload.spam).to be true\n      end\n    end\n\n    context \"when the message has a spam score greater than the server's spam failure threshold\" do\n      before do\n        inspection_result = double(\"Result\", spam_score: 100, threat: false, threat_message: nil, spam_checks: [])\n        allow(Postal::MessageInspection).to receive(:scan).and_return(inspection_result)\n      end\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/message has a spam score higher than the server's maxmimum/)\n      end\n\n      it \"sets the message status to HardFail\" do\n        processor.process\n        expect(message.reload.status).to eq \"HardFail\"\n      end\n\n      it \"creates a HardFail delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"HardFail\", details: /spam score is higher than the failure threshold for this server/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the server mode is Development and the message was not manually queued\" do\n      before do\n        server.update!(mode: \"Development\")\n      end\n\n      after do\n        server.update!(mode: \"Live\")\n      end\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/server is in development mode/)\n      end\n\n      it \"sets the message status to Held\" do\n        processor.process\n        expect(message.reload.status).to eq \"Held\"\n      end\n\n      it \"creates a Held delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"Held\", details: /server is in development mode/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when there is no route for the incoming message\" do\n      let(:route) { nil }\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/no route and\\/or endpoint available for processing/i)\n      end\n\n      it \"sets the message status to HardFail\" do\n        processor.process\n        expect(message.reload.status).to eq \"HardFail\"\n      end\n\n      it \"creates a HardFail delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"HardFail\", details: /does not have a route and\\/or endpoint available/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the route's spam mode is Quarantine, the message is spam and not manually queued\" do\n      let(:route) { create(:route, server: server, spam_mode: \"Quarantine\") }\n\n      before do\n        inspection_result = double(\"Result\", spam_score: server.spam_threshold + 1, threat: false, threat_message: nil, spam_checks: [])\n        allow(Postal::MessageInspection).to receive(:scan).and_return(inspection_result)\n      end\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/message is spam and route says to quarantine spam message/i)\n      end\n\n      it \"sets the message status to Held\" do\n        processor.process\n        expect(message.reload.status).to eq \"Held\"\n      end\n\n      it \"creates a Held delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"Held\", details: /message placed into quarantine/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the route's spam mode is Fail, the message is spam and not manually queued\" do\n      let(:route) { create(:route, server: server, spam_mode: \"Fail\") }\n\n      before do\n        inspection_result = double(\"Result\", spam_score: server.spam_threshold + 1, threat: false, threat_message: nil, spam_checks: [])\n        allow(Postal::MessageInspection).to receive(:scan).and_return(inspection_result)\n      end\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/message is spam and route says to fail spam message/i)\n      end\n\n      it \"sets the message status to HardFail\" do\n        processor.process\n        expect(message.reload.status).to eq \"HardFail\"\n      end\n\n      it \"creates a HardFail delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"HardFail\", details: /message is spam and the route specifies it should be failed/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the route's mode is Accept\" do\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/route says to accept without endpoint/i)\n      end\n\n      it \"sets the message status to Processed\" do\n        processor.process\n        expect(message.reload.status).to eq \"Processed\"\n      end\n\n      it \"creates a Processed delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"Processed\", details: /message has been accepted but not sent to any endpoints/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the route's mode is Hold\" do\n      let(:route) { create(:route, server: server, mode: \"Hold\") }\n\n      context \"when the message was queued manually\" do\n        let(:queued_message) { create(:queued_message, :locked, server: server, message: message, manual: true) }\n\n        it \"logs\" do\n          processor.process\n          expect(logger).to have_logged(/route says to hold and message was queued manually/i)\n        end\n\n        it \"sets the message status to Processed\" do\n          processor.process\n          expect(message.reload.status).to eq \"Processed\"\n        end\n\n        it \"creates a Processed delivery\" do\n          processor.process\n          delivery = message.deliveries.last\n          expect(delivery).to have_attributes(status: \"Processed\", details: /message has been processed/i)\n        end\n\n        it \"removes the queued message\" do\n          processor.process\n          expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n        end\n      end\n\n      context \"when the message was not queued manually\" do\n        let(:queued_message) { create(:queued_message, :locked, server: server, message: message, manual: false) }\n\n        it \"logs\" do\n          processor.process\n          expect(logger).to have_logged(/route says to hold, marking as held/i)\n        end\n\n        it \"sets the message status to Held\" do\n          processor.process\n          expect(message.reload.status).to eq \"Held\"\n        end\n\n        it \"creates a Held delivery\" do\n          processor.process\n          delivery = message.deliveries.last\n          expect(delivery).to have_attributes(status: \"Held\", details: /message has been accepted but not sent to any endpoints/i)\n        end\n\n        it \"removes the queued message\" do\n          processor.process\n          expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n        end\n      end\n    end\n\n    context \"when the route's mode is Bounce\" do\n      let(:route) { create(:route, server: server, mode: \"Bounce\") }\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/route says to bounce/i)\n      end\n\n      it \"sends a bounce\" do\n        expect(BounceMessage).to receive(:new).with(server, queued_message.message)\n        processor.process\n      end\n\n      it \"sets the message status to HardFail\" do\n        processor.process\n        expect(message.reload.status).to eq \"HardFail\"\n      end\n\n      it \"creates a HardFail delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"HardFail\", details: /message has been bounced because/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the route's mode is Reject\" do\n      let(:route) { create(:route, server: server, mode: \"Reject\") }\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/route says to bounce/i)\n      end\n\n      it \"sends a bounce\" do\n        expect(BounceMessage).to receive(:new).with(server, queued_message.message)\n        processor.process\n      end\n\n      it \"sets the message status to HardFail\" do\n        processor.process\n        expect(message.reload.status).to eq \"HardFail\"\n      end\n\n      it \"creates a HardFail delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"HardFail\", details: /message has been bounced because/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the route's endpoint is an HTTP endpoint\" do\n      let(:endpoint) { create(:http_endpoint, server: server) }\n      let(:route) { create(:route, server: server, mode: \"Endpoint\", endpoint: endpoint) }\n\n      it \"gets a sender from the state and sends the message to it\" do\n        http_sender_double = double(\"HTTPSender\")\n        expect(http_sender_double).to receive(:send_message).with(queued_message.message).and_return(SendResult.new)\n        expect(state).to receive(:sender_for).with(HTTPSender, endpoint).and_return(http_sender_double)\n        processor.process\n      end\n    end\n\n    context \"when the route's endpoint is an SMTP endpoint\" do\n      let(:endpoint) { create(:smtp_endpoint, server: server) }\n      let(:route) { create(:route, server: server, mode: \"Endpoint\", endpoint: endpoint) }\n\n      it \"gets a sender from the state and sends the message to it\" do\n        smtp_sender_double = double(\"SMTPSender\")\n        expect(smtp_sender_double).to receive(:send_message).with(queued_message.message).and_return(SendResult.new)\n        expect(state).to receive(:sender_for).with(SMTPSender, message.recipient_domain, nil, { servers: [kind_of(SMTPClient::Server)] }).and_return(smtp_sender_double)\n        processor.process\n      end\n    end\n\n    context \"when the route's endpoint is an Address endpoint\" do\n      let(:endpoint) { create(:address_endpoint, server: server) }\n      let(:route) { create(:route, server: server, mode: \"Endpoint\", endpoint: endpoint) }\n\n      it \"gets a sender from the state and sends the message to it\" do\n        smtp_sender_double = double(\"SMTPSender\")\n        expect(smtp_sender_double).to receive(:send_message).with(queued_message.message).and_return(SendResult.new)\n        expect(state).to receive(:sender_for).with(SMTPSender, endpoint.domain, nil, { rcpt_to: endpoint.address }).and_return(smtp_sender_double)\n        processor.process\n      end\n    end\n\n    context \"when the route's endpoint is an unknown endpoint\" do\n      let(:route) { create(:route, server: server, mode: \"Endpoint\", endpoint: create(:webhook, server: server)) }\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/invalid endpoint for route/i)\n      end\n\n      it \"sets the message status to HardFail\" do\n        processor.process\n        expect(message.reload.status).to eq \"HardFail\"\n      end\n\n      it \"creates a HardFail delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"HardFail\", details: /invalid endpoint for route/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the message has been sent to a sender\" do\n      let(:endpoint) { create(:smtp_endpoint, server: server) }\n      let(:route) { create(:route, server: server, mode: \"Endpoint\", endpoint: endpoint) }\n\n      let(:send_result) do\n        SendResult.new do |result|\n          result.type = \"Sent\"\n          result.details = \"Sent successfully\"\n        end\n      end\n\n      before do\n        smtp_sender_mock = double(\"SMTPSender\")\n        allow(SMTPSender).to receive(:new).and_return(smtp_sender_mock)\n        allow(smtp_sender_mock).to receive(:start)\n        allow(smtp_sender_mock).to receive(:finish)\n        allow(smtp_sender_mock).to receive(:send_message).and_return(send_result)\n      end\n\n      context \"when the sender returns a HardFail and bounces are suppressed\" do\n        before do\n          send_result.type = \"HardFail\"\n          send_result.suppress_bounce = true\n        end\n\n        it \"logs\" do\n          processor.process\n          expect(logger).to have_logged(/suppressing bounce message after hard fail/)\n        end\n\n        it \"does not send a bounce\" do\n          allow(BounceMessage).to receive(:new)\n          processor.process\n          expect(BounceMessage).to_not have_received(:new)\n        end\n      end\n\n      context \"when the sender returns a HardFail and bounces should be sent\" do\n        before do\n          send_result.type = \"HardFail\"\n          send_result.details = \"Failed to send message\"\n        end\n\n        it \"logs\" do\n          processor.process\n          expect(logger).to have_logged(/sending a bounce because message hard failed/)\n        end\n\n        it \"sends a bounce\" do\n          expect(BounceMessage).to receive(:new).with(server, queued_message.message)\n          processor.process\n        end\n\n        it \"sets the message status to HardFail\" do\n          processor.process\n          expect(message.reload.status).to eq \"HardFail\"\n        end\n\n        it \"creates a delivery with the details and a suffix about the bounce message\" do\n          processor.process\n          delivery = message.deliveries.last\n          expect(delivery).to have_attributes(status: \"HardFail\", details: /Failed to send message. Sent bounce message to sender \\(see message <msg:\\d+>\\)/i)\n        end\n      end\n\n      it \"creates a delivery with the result from the sender\" do\n        send_result.output = \"some output here\"\n        send_result.secure = true\n        send_result.log_id = \"12345\"\n        send_result.time = 2.32\n\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"Sent\",\n                                            details: \"Sent successfully\",\n                                            output: \"some output here\",\n                                            sent_with_ssl: true,\n                                            log_id: \"12345\",\n                                            time: 2.32)\n      end\n\n      context \"when the sender wants to retry\" do\n        before do\n          send_result.type = \"SoftFail\"\n          send_result.retry = true\n        end\n\n        it \"logs\" do\n          processor.process\n          expect(logger).to have_logged(/message requeued for trying later, at/i)\n        end\n\n        it \"sets the message status to SoftFail\" do\n          processor.process\n          expect(message.reload.status).to eq \"SoftFail\"\n        end\n\n        it \"updates the queued message with a new retry time\" do\n          Timecop.freeze do\n            retry_time = 5.minutes.from_now.change(usec: 0)\n            processor.process\n            expect(queued_message.reload.retry_after).to eq retry_time\n          end\n        end\n\n        it \"allocates a new IP address to send the message from and updates the queued message\" do\n          expect(queued_message).to receive(:allocate_ip_address)\n          processor.process\n        end\n\n        it \"does not remove the queued message\" do\n          processor.process\n          expect(queued_message.reload).to be_present\n        end\n      end\n\n      context \"when the sender does not want a retry\" do\n        it \"logs\" do\n          processor.process\n          expect(logger).to have_logged(/message processing completed/i)\n        end\n\n        it \"sets the message status to Sent\" do\n          processor.process\n          expect(message.reload.status).to eq \"Sent\"\n        end\n\n        it \"marks the endpoint as used\" do\n          route.endpoint.update!(last_used_at: nil)\n          Timecop.freeze do\n            expect { processor.process }.to change { route.endpoint.reload.last_used_at.to_i }.from(0).to(Time.now.to_i)\n          end\n        end\n\n        it \"removes the queued message\" do\n          processor.process\n          expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n        end\n      end\n    end\n\n    context \"when an exception occurrs during processing\" do\n      let(:endpoint) { create(:smtp_endpoint, server: server) }\n      let(:route) { create(:route, server: server, mode: \"Endpoint\", endpoint: endpoint) }\n\n      before do\n        smtp_sender_mock = double(\"SMTPSender\")\n        allow(SMTPSender).to receive(:new).and_return(smtp_sender_mock)\n        allow(smtp_sender_mock).to receive(:start)\n        allow(smtp_sender_mock).to receive(:finish)\n        allow(smtp_sender_mock).to receive(:send_message) do\n          1 / 0\n        end\n      end\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/internal error: ZeroDivisionError/i)\n      end\n\n      it \"creates an Error delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"Error\", details: /internal error/i)\n      end\n\n      it \"marks the message for retrying later\" do\n        processor.process\n        expect(queued_message.reload.retry_after).to be_present\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/message_dequeuer/initial_message_processor_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule MessageDequeuer\n\n  RSpec.describe InitialProcessor do\n    let(:server) { create(:server) }\n    let(:logger) { TestLogger.new }\n    let(:route) { create(:route, server: server) }\n    let(:message) { MessageFactory.incoming(server, route: route) }\n    let(:queued_message) { create(:queued_message, :locked, message: message) }\n\n    subject(:processor) { described_class.new(queued_message, logger: logger) }\n\n    it \"has state when not given any\" do\n      expect(processor.state).to be_a State\n    end\n\n    context \"when associated message does not exist\" do\n      let(:queued_message) { create(:queued_message, :locked, message_id: 12_345) }\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/unqueue because backend message has been removed/)\n      end\n\n      it \"removes from queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the queued message is not ready for processing\" do\n      let(:queued_message) { create(:queued_message, :locked, message: message, retry_after: 1.hour.from_now) }\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/skipping because message isn't ready for processing/)\n      end\n\n      it \"unlocks and keeps the queued message\" do\n        processor.process\n        expect(queued_message.reload).to_not be_locked\n      end\n    end\n\n    context \"when there are no other batchable messages\" do\n      it \"calls the single message processor for the initial message\" do\n        expect(SingleMessageProcessor).to receive(:process).with(queued_message,\n                                                                 logger: logger,\n                                                                 state: processor.state)\n        processor.process\n      end\n    end\n\n    context \"when there are batchable messages\" do\n      before do\n        @message2 = MessageFactory.incoming(server, route: route)\n        @queued_message2 = create(:queued_message, message: @message2)\n        @message3 = MessageFactory.incoming(server, route: route)\n        @queued_message3 = create(:queued_message, message: @message3)\n      end\n\n      context \"when postal.batch_queued_messages is enabled\" do\n        it \"calls the single message process for the initial message and all batchable messages\" do\n          [queued_message, @queued_message2, @queued_message3].each do |msg|\n            expect(SingleMessageProcessor).to receive(:process).with(msg,\n                                                                     logger: logger,\n                                                                     state: processor.state)\n          end\n          processor.process\n        end\n      end\n\n      context \"when postal.batch_queued_messages is disabled\" do\n        before do\n          allow(Postal::Config.postal).to receive(:batch_queued_messages?) { false }\n        end\n\n        it \"does not call the single message process more than once\" do\n          expect(SingleMessageProcessor).to receive(:process).once.with(queued_message,\n                                                                        logger: logger,\n                                                                        state: processor.state)\n          processor.process\n        end\n      end\n    end\n\n    context \"when an error occurs while finding batchable messages\" do\n      before do\n        allow(queued_message).to receive(:batchable_messages) { 1 / 0 }\n      end\n\n      it \"unlocks the queued message and raises the error\" do\n        expect { processor.process }.to raise_error(ZeroDivisionError)\n        expect(queued_message.reload).to_not be_locked\n      end\n    end\n\n    context \"when finished\" do\n      it \"notifies the state that processing is complete\" do\n        expect(processor.state).to receive(:finished)\n        processor.process\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/message_dequeuer/outgoing_message_processor_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule MessageDequeuer\n\n  RSpec.describe OutgoingMessageProcessor do\n    let(:server) { create(:server) }\n    let(:state) { State.new }\n    let(:logger) { TestLogger.new }\n    let(:domain) { create(:domain, server: server) }\n    let(:credential) { create(:credential, server: server) }\n    let(:message) { MessageFactory.outgoing(server, domain: domain, credential: credential) }\n    let(:queued_message) { create(:queued_message, :locked, message: message) }\n\n    subject(:processor) { described_class.new(queued_message, logger: logger, state: state) }\n\n    context \"when the domain belonging to the message no longer exists\" do\n      let(:message) { MessageFactory.outgoing(server, domain: nil, credential: credential) }\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/message has no domain/)\n      end\n\n      it \"sets the message status to HardFail\" do\n        processor.process\n        expect(message.reload.status).to eq \"HardFail\"\n      end\n\n      it \"creates a HardFail delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"HardFail\", details: /Message's domain no longer exist/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the message has no rcpt to address\" do\n      before do\n        message.update(rcpt_to: \"\")\n      end\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/message has no 'to' address/)\n      end\n\n      it \"sets the message status to HardFail\" do\n        processor.process\n        expect(message.reload.status).to eq \"HardFail\"\n      end\n\n      it \"creates a HardFail delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"HardFail\", details: /Message doesn't have an RCPT to/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the message has a x-postal-tag header\" do\n      let(:message) do\n        MessageFactory.outgoing(server, domain: domain) do |_msg, mail|\n          mail[\"x-postal-tag\"] = \"example-tag\"\n        end\n      end\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/added tag: example-tag/)\n      end\n\n      it \"adds the tag to the message object\" do\n        processor.process\n        expect(message.reload.tag).to eq(\"example-tag\")\n      end\n    end\n\n    context \"when the credential says to hold the message\" do\n      let(:credential) { create(:credential, hold: true) }\n\n      context \"when the message was queued manually\" do\n        let(:queued_message) { create(:queued_message, :locked, message: message, manual: true) }\n\n        it \"does not hold the message\" do\n          processor.process\n          deliveries = message.deliveries.find { |d| d.status == \"Held\" }\n          expect(deliveries).to be_nil\n        end\n      end\n\n      context \"when the message was not queued manually\" do\n        it \"logs\" do\n          processor.process\n          expect(logger).to have_logged(/credential wants us to hold messages/)\n        end\n\n        it \"sets the message status to Held\" do\n          processor.process\n          expect(message.reload.status).to eq \"Held\"\n        end\n\n        it \"creates a Held delivery\" do\n          processor.process\n          delivery = message.deliveries.last\n          expect(delivery).to have_attributes(status: \"Held\", details: /Credential is configured to hold all messages authenticated/i)\n        end\n\n        it \"removes the queued message\" do\n          processor.process\n          expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n        end\n      end\n    end\n\n    context \"when the rcpt address is on the suppression list\" do\n      before do\n        server.message_db.suppression_list.add(:recipient, message.rcpt_to, reason: \"testing\")\n      end\n\n      context \"when the message was queued manually\" do\n        let(:queued_message) { create(:queued_message, :locked, message: message, manual: true) }\n\n        it \"does not hold the message\" do\n          processor.process\n          deliveries = message.deliveries.find { |d| d.status == \"Held\" }\n          expect(deliveries).to be_nil\n        end\n      end\n\n      context \"when the message was not queued manually\" do\n        it \"logs\" do\n          processor.process\n          expect(logger).to have_logged(/recipient is on the suppression list/)\n        end\n\n        it \"sets the message status to Held\" do\n          processor.process\n          expect(message.reload.status).to eq \"Held\"\n        end\n\n        it \"creates a Held delivery\" do\n          processor.process\n          delivery = message.deliveries.last\n          expect(delivery).to have_attributes(status: \"Held\", details: /Recipient \\(#{message.rcpt_to}\\) is on the suppression list/i)\n        end\n\n        it \"removes the queued message\" do\n          processor.process\n          expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n        end\n      end\n    end\n\n    context \"when the message content has not been parsed\" do\n      it \"parses the content\" do\n        mocked_parser = double(\"Result\")\n        allow(mocked_parser).to receive(:actioned?).and_return(false)\n        allow(mocked_parser).to receive(:tracked_links).and_return(0)\n        allow(mocked_parser).to receive(:tracked_images).and_return(0)\n        expect(Postal::MessageParser).to receive(:new).with(kind_of(Postal::MessageDB::Message)).and_return(mocked_parser)\n        processor.process\n        reloaded_message = message.reload\n        expect(reloaded_message.parsed).to eq 1\n        expect(reloaded_message.tracked_links).to eq 0\n        expect(reloaded_message.tracked_images).to eq 0\n      end\n    end\n\n    context \"when the server has an outbound spam threshold configured\" do\n      let(:server) { create(:server, outbound_spam_threshold: 5.0) }\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/inspecting message/)\n        expect(logger).to have_logged(/message inspected successfully/)\n      end\n\n      it \"inspects the message\" do\n        inspection_result = double(\"Result\", spam_score: 1.0, threat: false, threat_message: nil, spam_checks: [])\n        expect(Postal::MessageInspection).to receive(:scan).and_return(inspection_result)\n        processor.process\n      end\n\n      context \"when the message spam score is higher than the threshold\" do\n        before do\n          inspection_result = double(\"Result\", spam_score: 6.0, threat: false, threat_message: nil, spam_checks: [])\n          allow(Postal::MessageInspection).to receive(:scan).and_return(inspection_result)\n        end\n\n        it \"logs\" do\n          processor.process\n          expect(logger).to have_logged(/message is spam/)\n        end\n\n        it \"sets the spam boolean on the message\" do\n          processor.process\n          expect(message.reload.spam).to be true\n        end\n\n        it \"sets the message status to HardFail\" do\n          processor.process\n          expect(message.reload.status).to eq \"HardFail\"\n        end\n\n        it \"creates a HardFail delivery\" do\n          processor.process\n          delivery = message.deliveries.last\n          expect(delivery).to have_attributes(status: \"HardFail\", details: /Message is likely spam. Threshold is 5.0 and the message scored 6.0/i)\n        end\n\n        it \"removes the queued message\" do\n          processor.process\n          expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n        end\n      end\n    end\n\n    context \"when the server does not have a outbound spam threshold configured\" do\n      it \"does not inspect the message\" do\n        expect(Postal::MessageInspection).to_not receive(:scan)\n        processor.process\n      end\n    end\n\n    context \"when the message already has an x-postal-msgid header\" do\n      let(:message) do\n        MessageFactory.outgoing(server, domain: domain, credential: credential) do |_, mail|\n          mail[\"x-postal-msgid\"] = \"existing-id\"\n        end\n      end\n\n      it \"does not another one\" do\n        processor.process\n        expect(message.reload.headers[\"x-postal-msgid\"]).to eq [\"existing-id\"]\n      end\n\n      it \"does not add dkim headers\" do\n        processor.process\n        expect(message.reload.headers[\"dkim-signature\"]).to be_nil\n      end\n    end\n\n    context \"when the message does not have a x-postal-msgid header\" do\n      it \"adds it\" do\n        processor.process\n        expect(message.reload.headers[\"x-postal-msgid\"]).to match [match(/[a-zA-Z0-9]{12}/)]\n      end\n\n      it \"adds a dkim header\" do\n        processor.process\n        expect(message.reload.headers[\"dkim-signature\"]).to match [match(/\\Av=1; a=rsa-sha256/)]\n      end\n    end\n\n    context \"when the server has exceeded its send limit\" do\n      let(:server) { create(:server, send_limit: 5) }\n\n      before do\n        5.times { server.message_db.live_stats.increment(\"outgoing\") }\n      end\n\n      it \"updates the time the limit was exceeded\" do\n        expect { processor.process }.to change { server.reload.send_limit_exceeded_at }.from(nil).to(kind_of(Time))\n      end\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/server send limit has been exceeded/)\n      end\n\n      it \"sets the message status to Held\" do\n        processor.process\n        expect(message.reload.status).to eq \"Held\"\n      end\n\n      it \"creates a Held delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"Held\", details: /Message held because send limit \\(5\\) has been reached/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the server is approaching its send limit\" do\n      let(:server) { create(:server, send_limit: 10) }\n\n      before do\n        9.times { server.message_db.live_stats.increment(\"outgoing\") }\n      end\n\n      it \"updates the time the limit was being approached\" do\n        expect { processor.process }.to change { server.reload.send_limit_approaching_at }.from(nil).to(kind_of(Time))\n      end\n\n      it \"does not set the exceeded time\" do\n        expect { processor.process }.to_not change { server.reload.send_limit_exceeded_at } # rubocop:disable Lint/AmbiguousBlockAssociation\n      end\n    end\n\n    context \"when the server is not exceeded or approaching its limit\" do\n      let(:server) { create(:server, :exceeded_send_limit, send_limit: 10) }\n\n      it \"clears the approaching and exceeded limits\" do\n        processor.process\n        server.reload\n        expect(server.send_limit_approaching_at).to be_nil\n        expect(server.send_limit_exceeded_at).to be_nil\n      end\n    end\n\n    context \"when the server is in development mode\" do\n      let(:server) { create(:server, mode: \"Development\") }\n\n      context \"when the message was queued manually\" do\n        let(:queued_message) { create(:queued_message, :locked, message: message, manual: true) }\n\n        it \"does not hold the message\" do\n          processor.process\n          deliveries = message.deliveries.find { |d| d.status == \"Held\" }\n          expect(deliveries).to be_nil\n        end\n      end\n\n      context \"when the message was not queued manually\" do\n        it \"logs\" do\n          processor.process\n          expect(logger).to have_logged(/server is in development mode/)\n        end\n\n        it \"sets the message status to Held\" do\n          processor.process\n          expect(message.reload.status).to eq \"Held\"\n        end\n\n        it \"creates a Held delivery\" do\n          processor.process\n          delivery = message.deliveries.last\n          expect(delivery).to have_attributes(status: \"Held\", details: /Server is in development mode/i)\n        end\n\n        it \"removes the queued message\" do\n          processor.process\n          expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n        end\n      end\n    end\n\n    context \"when there are no other impediments\" do\n      let(:send_result) do\n        SendResult.new do |r|\n          r.type = \"Sent\"\n        end\n      end\n\n      before do\n        mocked_sender = double(\"SMTPSender\")\n        allow(mocked_sender).to receive(:send_message).and_return(send_result)\n        allow(state).to receive(:sender_for).and_return(mocked_sender)\n      end\n\n      it \"increments the live stats\" do\n        expect { processor.process }.to change { server.message_db.live_stats.total(60) }.from(0).to(1)\n      end\n\n      context \"when there is an IP address assigned to the queued message\" do\n        let(:ip) { create(:ip_address) }\n        let(:queued_message) { create(:queued_message, :locked, message: message, ip_address: ip) }\n\n        it \"gets a sender from the state and sends the message to it\" do\n          mocked_sender = double(\"SMTPSender\")\n          expect(mocked_sender).to receive(:send_message).with(queued_message.message).and_return(send_result)\n          expect(state).to receive(:sender_for).with(SMTPSender, message.recipient_domain, ip).and_return(mocked_sender)\n\n          processor.process\n        end\n      end\n\n      context \"when there is no IP address assigned to the queued message\" do\n        it \"gets a sender from the state and sends the message to it\" do\n          mocked_sender = double(\"SMTPSender\")\n          expect(mocked_sender).to receive(:send_message).with(queued_message.message).and_return(send_result)\n          expect(state).to receive(:sender_for).with(SMTPSender, message.recipient_domain, nil).and_return(mocked_sender)\n\n          processor.process\n        end\n      end\n\n      context \"when the message hard fails\" do\n        before do\n          send_result.type = \"HardFail\"\n        end\n\n        context \"when the recipient has got no hard fails in the last 24 hours\" do\n          it \"does not add to the suppression list\" do\n            processor.process\n            expect(server.message_db.suppression_list.all_with_pagination(1)[:total]).to eq 0\n          end\n        end\n\n        context \"when the recipient has more than one hard fail in the last 24 hours\" do\n          before do\n            2.times do\n              MessageFactory.outgoing(server, domain: domain, credential: credential) do |msg|\n                msg.status = \"HardFail\"\n              end\n            end\n          end\n\n          it \"logs\" do\n            processor.process\n            expect(logger).to have_logged(/added #{message.rcpt_to} to suppression list because 2 hard fails in 24 hours/i)\n          end\n\n          it \"adds the recipient to the suppression list\" do\n            processor.process\n            entry = server.message_db.suppression_list.get(:recipient, message.rcpt_to)\n            expect(entry).to match hash_including(\n              \"address\" => message.rcpt_to,\n              \"type\" => \"recipient\",\n              \"reason\" => \"too many hard fails\"\n            )\n          end\n        end\n      end\n\n      context \"when the message is sent manually and the recipient is on the suppression list\" do\n        let(:queued_message) { create(:queued_message, :locked, message: message, manual: true) }\n\n        before do\n          server.message_db.suppression_list.add(:recipient, message.rcpt_to, reason: \"testing\")\n        end\n\n        it \"logs\" do\n          processor.process\n          expect(logger).to have_logged(/removed #{message.rcpt_to} from suppression list/)\n        end\n\n        it \"removes them from the suppression list\" do\n          processor.process\n          expect(server.message_db.suppression_list.get(:recipient, message.rcpt_to)).to be_nil\n        end\n\n        it \"adds the details to the delivery details\" do\n          processor.process\n          delivery = message.deliveries.last\n          expect(delivery.details).to include(\"Recipient removed from suppression list\")\n        end\n      end\n\n      it \"creates a delivery with the appropriate details\" do\n        send_result.details = \"Sent successfully to mx.example.com\"\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"Sent\", details: \"Sent successfully to mx.example.com\")\n      end\n\n      context \"if the message should be retried\" do\n        before do\n          send_result.type = \"SoftFail\"\n          send_result.retry = true\n        end\n\n        it \"logs\" do\n          processor.process\n          expect(logger).to have_logged(/message requeued for trying later/)\n        end\n\n        it \"sets the message status to SoftFail\" do\n          processor.process\n          expect(message.reload.status).to eq \"SoftFail\"\n        end\n\n        it \"updates the retry time on the queued message\" do\n          Timecop.freeze do\n            retry_time = 5.minutes.from_now.change(usec: 0)\n            processor.process\n            expect(queued_message.reload.retry_after).to eq retry_time\n          end\n        end\n      end\n\n      context \"if the message should not be retried\" do\n        it \"logs\" do\n          processor.process\n          expect(logger).to have_logged(/message processing complete/)\n        end\n\n        it \"sets the message status to Sent\" do\n          processor.process\n          expect(message.reload.status).to eq \"Sent\"\n        end\n\n        it \"removes the queued message\" do\n          processor.process\n          expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n        end\n      end\n    end\n\n    context \"when an exception occurrs during processing\" do\n      before do\n        smtp_sender_mock = double(\"SMTPSender\")\n        allow(SMTPSender).to receive(:new).and_return(smtp_sender_mock)\n        allow(smtp_sender_mock).to receive(:start)\n        allow(smtp_sender_mock).to receive(:send_message) do\n          1 / 0\n        end\n      end\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/internal error: ZeroDivisionError/i)\n      end\n\n      it \"creates an Error delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"Error\", details: /internal error/i)\n      end\n\n      it \"marks the message for retrying later\" do\n        processor.process\n        expect(queued_message.reload.retry_after).to be_present\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/message_dequeuer/single_message_processor_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule MessageDequeuer\n\n  RSpec.describe SingleMessageProcessor do\n    let(:server) { create(:server) }\n    let(:state) { State.new }\n    let(:logger) { TestLogger.new }\n    let(:route) { create(:route, server: server) }\n    let(:message) { MessageFactory.incoming(server, route: route) }\n    let(:queued_message) { create(:queued_message, :locked, message: message) }\n\n    subject(:processor) { described_class.new(queued_message, logger: logger, state: state) }\n\n    context \"when the server is suspended\" do\n      before do\n        allow(queued_message.server).to receive(:suspended?).and_return(true)\n      end\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/server is suspended/)\n      end\n\n      it \"sets the message status to Held\" do\n        processor.process\n        expect(message.reload.status).to eq \"Held\"\n      end\n\n      it \"creates a Held delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"Held\", details: /server has been suspended/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the number of attempts is more than the maximum\" do\n      let(:queued_message) { create(:queued_message, :locked, message: message, attempts: Postal::Config.postal.default_maximum_delivery_attempts + 1) }\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/message has reached maximum number of attempts/)\n      end\n\n      it \"sends a bounce to the sender\" do\n        expect(BounceMessage).to receive(:new).with(server, queued_message.message)\n        processor.process\n      end\n\n      it \"sets the message status to HardFail\" do\n        processor.process\n        expect(message.reload.status).to eq \"HardFail\"\n      end\n\n      it \"creates a HardFail delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"HardFail\", details: /maximum number of delivery attempts.*bounce sent to sender/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the message raw data has been removed\" do\n      before do\n        message.raw_table = nil\n        message.save\n      end\n\n      it \"logs\" do\n        processor.process\n        expect(logger).to have_logged(/raw message has been removed/)\n      end\n\n      it \"sets the message status to HardFail\" do\n        processor.process\n        expect(message.reload.status).to eq \"HardFail\"\n      end\n\n      it \"creates a HardFail delivery\" do\n        processor.process\n        delivery = message.deliveries.last\n        expect(delivery).to have_attributes(status: \"HardFail\", details: /Raw message has been removed/i)\n      end\n\n      it \"removes the queued message\" do\n        processor.process\n        expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n\n    context \"when the message is incoming\" do\n      it \"calls the incoming message processor\" do\n        expect(IncomingMessageProcessor).to receive(:new).with(queued_message,\n                                                               logger: logger,\n                                                               state: processor.state)\n        processor.process\n      end\n\n      it \"does not call the outgoing message processor\" do\n        expect(OutgoingMessageProcessor).to_not receive(:process)\n        processor.process\n      end\n    end\n\n    context \"when the message is outgoing\" do\n      let(:message) { MessageFactory.outgoing(server) }\n\n      it \"calls the outgoing message processor\" do\n        expect(OutgoingMessageProcessor).to receive(:process).with(queued_message,\n                                                                   logger: logger,\n                                                                   state: processor.state)\n\n        processor.process\n      end\n\n      it \"does not call the incoming message processor\" do\n        expect(IncomingMessageProcessor).to_not receive(:process)\n        processor.process\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/message_dequeuer/state_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule MessageDequeuer\n\n  RSpec.describe State do\n    subject(:state) { described_class.new }\n\n    describe \"#send_result\" do\n      it \"can be get and set\" do\n        result = instance_double(SendResult)\n        state.send_result = result\n        expect(state.send_result).to be result\n      end\n    end\n\n    describe \"#sender_for\" do\n      it \"returns a instance of the given sender initialized with the args\" do\n        sender = state.sender_for(HTTPSender, \"1234\")\n        expect(sender).to be_a HTTPSender\n      end\n\n      it \"returns a cached sender on subsequent calls\" do\n        sender = state.sender_for(HTTPSender, \"1234\")\n        expect(state.sender_for(HTTPSender, \"1234\")).to be sender\n      end\n    end\n\n    describe \"#finished\" do\n      it \"calls finish on all cached senders\" do\n        sender1 = state.sender_for(HTTPSender, \"1234\")\n        sender2 = state.sender_for(HTTPSender, \"4444\")\n        expect(sender1).to receive(:finish)\n        expect(sender2).to receive(:finish)\n\n        state.finished\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/message_dequeuer_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nRSpec.describe MessageDequeuer do\n  describe \".process\" do\n    it \"calls the initial process with the given message and logger\" do\n      message = create(:queued_message)\n      logger = TestLogger.new\n\n      mock = double(\"InitialProcessor\")\n      expect(mock).to receive(:process).with(no_args)\n      expect(MessageDequeuer::InitialProcessor).to receive(:new).with(message, logger: logger).and_return(mock)\n\n      described_class.process(message, logger: logger)\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/postal/legacy_config_source_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule Postal\n\n  SOURCE_CONFIG = YAML.safe_load(File.read(Rails.root.join(\"spec/examples/full_legacy_config_file.yml\")))\n\n  # Rather than actuall test the LegacyConfigSource directly, I have decided\n  # to test this source via. the Konfig::Config system to ensure it works as\n  # expected in practice rather than just in theory. Testing '#get' would be\n  # fairly easy (and mostly pointless) where as testing the values we actually\n  # want are correct is preferred.\n  RSpec.describe LegacyConfigSource do\n    before do\n      # For the purposes of testing, we want to ignore any defaults provided\n      # by the schema itself. Otherwise, we might see a value returned that\n      # looks correct but is actually the default rather than the value from\n      # config file.\n      allow_any_instance_of(Konfig::SchemaAttribute).to receive(:default) do |a|\n        a.array? ? [] : nil\n      end\n    end\n\n    let(:source) { described_class.new(SOURCE_CONFIG) }\n    subject(:config) { Konfig::Config.build(ConfigSchema, sources: [source]) }\n\n    describe \"the 'postal' group\" do\n      it \"returns a value for postal.web_hostname\" do\n        expect(config.postal.web_hostname).to eq \"postal.llamas.com\"\n      end\n\n      it \"returns a value for postal.web_protocol\" do\n        expect(config.postal.web_protocol).to eq \"https\"\n      end\n\n      it \"returns a value for postal.smtp_hostname\" do\n        expect(config.postal.smtp_hostname).to eq \"smtp.postal.llamas.com\"\n      end\n\n      it \"returns a value for postal.use_ip_pools?\" do\n        expect(config.postal.use_ip_pools?).to eq false\n      end\n\n      it \"returns a value for postal.default_maximum_delivery_attempts\" do\n        expect(config.postal.default_maximum_delivery_attempts).to eq 20\n      end\n\n      it \"returns a value for postal.default_maximum_hold_expiry_days\" do\n        expect(config.postal.default_maximum_hold_expiry_days).to eq 10\n      end\n\n      it \"returns a value for postal.default_suppression_list_automatic_removal_days\" do\n        expect(config.postal.default_suppression_list_automatic_removal_days).to eq 60\n      end\n\n      it \"returns a value for postal.use_local_ns_for_domain_verification?\" do\n        expect(config.postal.use_local_ns_for_domain_verification?).to eq true\n      end\n\n      it \"returns a value for postal.default_spam_threshold\" do\n        expect(config.postal.default_spam_threshold).to eq 10\n      end\n\n      it \"returns a value for postal.default_spam_failure_threshold\" do\n        expect(config.postal.default_spam_failure_threshold).to eq 25\n      end\n\n      it \"returns a value for postal.use_resent_sender_header?\" do\n        expect(config.postal.use_resent_sender_header?).to eq true\n      end\n\n      it \"returns a value for postal.smtp_relays\" do\n        expect(config.postal.smtp_relays).to eq [\n          { \"host\" => \"1.2.3.4\", \"port\" => 25, \"ssl_mode\" => \"Auto\" },\n          { \"host\" => \"2.2.2.2\", \"port\" => 2525, \"ssl_mode\" => \"None\" },\n        ]\n      end\n    end\n\n    describe \"the 'web_server' group\" do\n      it \"returns a value for web_server.default_bind_address\" do\n        expect(config.web_server.default_bind_address).to eq \"127.0.0.1\"\n      end\n\n      it \"returns a value for web_server.default_port\" do\n        expect(config.web_server.default_port).to eq 6000\n      end\n\n      it \"returns a value for web_server.max_threads\" do\n        expect(config.web_server.max_threads).to eq 10\n      end\n    end\n\n    describe \"the 'main_db' group\" do\n      it \"returns a value for main_db.host\" do\n        expect(config.main_db.host).to eq \"localhost\"\n      end\n\n      it \"returns a value for main_db.port\" do\n        expect(config.main_db.port).to eq 3306\n      end\n\n      it \"returns a value for main_db.username\" do\n        expect(config.main_db.username).to eq \"postal\"\n      end\n\n      it \"returns a value for main_db.password\" do\n        expect(config.main_db.password).to eq \"t35tpassword\"\n      end\n\n      it \"returns a value for main_db.database\" do\n        expect(config.main_db.database).to eq \"postal\"\n      end\n\n      it \"returns a value for main_db.pool_size\" do\n        expect(config.main_db.pool_size).to eq 20\n      end\n\n      it \"returns a value for main_db.encoding\" do\n        expect(config.main_db.encoding).to eq \"utf8mb4\"\n      end\n    end\n\n    describe \"the 'message_db' group\" do\n      it \"returns a value for message_db.host\" do\n        expect(config.message_db.host).to eq \"localhost\"\n      end\n\n      it \"returns a value for message_db.port\" do\n        expect(config.message_db.port).to eq 3306\n      end\n\n      it \"returns a value for message_db.username\" do\n        expect(config.message_db.username).to eq \"postal\"\n      end\n\n      it \"returns a value for message_db.password\" do\n        expect(config.message_db.password).to eq \"p05t41\"\n      end\n\n      it \"returns a value for message_db.database_name_prefix\" do\n        expect(config.message_db.database_name_prefix).to eq \"postal\"\n      end\n    end\n\n    describe \"the 'logging' group\" do\n      it \"returns a value for logging.rails_log_enabled\" do\n        expect(config.logging.rails_log_enabled).to eq true\n      end\n    end\n\n    describe \"the 'gelf' group\" do\n      it \"returns a value for gelf.host\" do\n        expect(config.gelf.host).to eq \"logs.llamas.com\"\n      end\n\n      it \"returns a value for gelf.port\" do\n        expect(config.gelf.port).to eq 12_201\n      end\n\n      it \"returns a value for gelf.facility\" do\n        expect(config.gelf.facility).to eq \"mailer\"\n      end\n    end\n\n    describe \"the 'smtp_server' group\" do\n      it \"returns a value for smtp_server.default_port\" do\n        expect(config.smtp_server.default_port).to eq 25\n      end\n\n      it \"returns a value for smtp_server.default_bind_address\" do\n        expect(config.smtp_server.default_bind_address).to eq \"127.0.0.1\"\n      end\n\n      it \"returns a value for smtp_server.tls_enabled\" do\n        expect(config.smtp_server.tls_enabled).to eq true\n      end\n\n      it \"returns a value for smtp_server.tls_certificate_path\" do\n        expect(config.smtp_server.tls_certificate_path).to eq \"config/smtp.cert\"\n      end\n\n      it \"returns a value for smtp_server.tls_private_key_path\" do\n        expect(config.smtp_server.tls_private_key_path).to eq \"config/smtp.key\"\n      end\n\n      it \"returns a value for smtp_server.tls_ciphers\" do\n        expect(config.smtp_server.tls_ciphers).to eq \"abc\"\n      end\n\n      it \"returns a value for smtp_server.ssl_version\" do\n        expect(config.smtp_server.ssl_version).to eq \"SSLv23\"\n      end\n\n      it \"returns a value for smtp_server.proxy_protocol\" do\n        expect(config.smtp_server.proxy_protocol).to eq false\n      end\n\n      it \"returns a value for smtp_server.log_connections\" do\n        expect(config.smtp_server.log_connections).to eq true\n      end\n\n      it \"returns a value for smtp_server.max_message_size\" do\n        expect(config.smtp_server.max_message_size).to eq 10\n      end\n    end\n\n    describe \"the 'dns' group\" do\n      it \"returns a value for dns.mx_records\" do\n        expect(config.dns.mx_records).to eq [\"mx1.postal.llamas.com\", \"mx2.postal.llamas.com\"]\n      end\n\n      it \"returns a value for dns.spf_include\" do\n        expect(config.dns.spf_include).to eq \"spf.postal.llamas.com\"\n      end\n\n      it \"returns a value for dns.return_path_domain\" do\n        expect(config.dns.return_path_domain).to eq \"rp.postal.llamas.com\"\n      end\n\n      it \"returns a value for dns.route_domain\" do\n        expect(config.dns.route_domain).to eq \"routes.postal.llamas.com\"\n      end\n\n      it \"returns a value for dns.track_domain\" do\n        expect(config.dns.track_domain).to eq \"track.postal.llamas.com\"\n      end\n\n      it \"returns a value for dns.helo_hostname\" do\n        expect(config.dns.helo_hostname).to eq \"helo.postal.llamas.com\"\n      end\n\n      it \"returns a value for dns.dkim_identifier\" do\n        expect(config.dns.dkim_identifier).to eq \"postal\"\n      end\n\n      it \"returns a value for dns.domain_verify_prefix\" do\n        expect(config.dns.domain_verify_prefix).to eq \"postal-verification\"\n      end\n\n      it \"returns a value for dns.custom_return_path_prefix\" do\n        expect(config.dns.custom_return_path_prefix).to eq \"psrp\"\n      end\n    end\n\n    describe \"the 'smtp' group\" do\n      it \"returns a value for smtp.host\" do\n        expect(config.smtp.host).to eq \"127.0.0.1\"\n      end\n\n      it \"returns a value for smtp.port\" do\n        expect(config.smtp.port).to eq 25\n      end\n\n      it \"returns a value for smtp.username\" do\n        expect(config.smtp.username).to eq \"postalserver\"\n      end\n\n      it \"returns a value for smtp.password\" do\n        expect(config.smtp.password).to eq \"llama\"\n      end\n\n      it \"returns a value for smtp.from_name\" do\n        expect(config.smtp.from_name).to eq \"Postal\"\n      end\n\n      it \"returns a value for smtp.from_address\" do\n        expect(config.smtp.from_address).to eq \"postal@llamas.com\"\n      end\n    end\n\n    describe \"the 'rails' group\" do\n      it \"returns a value for rails.environment\" do\n        expect(config.rails.environment).to eq \"production\"\n      end\n\n      it \"returns a value for rails.secret_key\" do\n        expect(config.rails.secret_key).to eq \"abcdef123123123123123\"\n      end\n    end\n\n    describe \"the 'rspamd' group\" do\n      it \"returns a value for rspamd.enabled\" do\n        expect(config.rspamd.enabled).to eq true\n      end\n\n      it \"returns a value for rspamd.host\" do\n        expect(config.rspamd.host).to eq \"rspamd.llamas.com\"\n      end\n\n      it \"returns a value for rspamd.port\" do\n        expect(config.rspamd.port).to eq 11_334\n      end\n\n      it \"returns a value for rspamd.ssl?\" do\n        expect(config.rspamd.ssl?).to eq false\n      end\n\n      it \"returns a value for rspamd.password\" do\n        expect(config.rspamd.password).to eq \"llama\"\n      end\n\n      it \"returns a value for rspamd.flags\" do\n        expect(config.rspamd.flags).to eq \"abc\"\n      end\n    end\n\n    describe \"the 'spamd' group\" do\n      it \"returns a value for spamd.enabled\" do\n        expect(config.spamd.enabled).to eq false\n      end\n\n      it \"returns a value for spamd.host\" do\n        expect(config.spamd.host).to eq \"spamd.llamas.com\"\n      end\n\n      it \"returns a value for spamd.port\" do\n        expect(config.spamd.port).to eq 783\n      end\n    end\n\n    describe \"the 'clamav' group\" do\n      it \"returns a value for clamav.enabled\" do\n        expect(config.clamav.enabled).to eq false\n      end\n\n      it \"returns a value for clamav.host\" do\n        expect(config.clamav.host).to eq \"clamav.llamas.com\"\n      end\n\n      it \"returns a value for clamav.port\" do\n        expect(config.clamav.port).to eq 2000\n      end\n    end\n\n    describe \"the 'smtp_client' group\" do\n      it \"returns a value for smtp_client.open_timeout\" do\n        expect(config.smtp_client.open_timeout).to eq 60\n      end\n\n      it \"returns a value for smtp_client.read_timeout\" do\n        expect(config.smtp_client.read_timeout).to eq 120\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/postal/message_db/connection_pool_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\ndescribe Postal::MessageDB::ConnectionPool do\n  subject(:pool) { described_class.new }\n\n  describe \"#use\" do\n    it \"yields a connection\" do\n      counter = 0\n      pool.use do |connection|\n        expect(connection).to be_a Mysql2::Client\n        counter += 1\n      end\n      expect(counter).to eq 1\n    end\n\n    it \"checks in a connection after the block has executed\" do\n      connection = nil\n      pool.use do |c|\n        expect(pool.connections).to be_empty\n        connection = c\n      end\n      expect(pool.connections).to eq [connection]\n    end\n\n    it \"checks in a connection if theres an error in the block\" do\n      expect do\n        pool.use do\n          raise StandardError\n        end\n      end.to raise_error StandardError\n      expect(pool.connections).to match [kind_of(Mysql2::Client)]\n    end\n\n    it \"does not check in connections when there is a connection error\" do\n      expect do\n        pool.use do\n          raise Mysql2::Error, \"lost connection to server\"\n        end\n      end.to raise_error Mysql2::Error\n      expect(pool.connections).to eq []\n    end\n\n    it \"retries the block once if there is a connection error\" do\n      clients_seen = []\n      expect do\n        pool.use do |client|\n          clients_seen << client\n          raise Mysql2::Error, \"lost connection to server\"\n        end\n      end.to raise_error Mysql2::Error\n      expect(clients_seen.uniq.size).to eq 2\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/postal/message_db/database_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\ndescribe Postal::MessageDB::Database do\n  context \"when provisioned\" do\n    let(:server) { create(:server) }\n    subject(:database) { server.message_db }\n\n    it \"should be a message db\" do\n      expect(database).to be_a Postal::MessageDB::Database\n    end\n\n    it \"should return the current schema version\" do\n      expect(database.schema_version).to be_a Integer\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/postal/message_parser_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\ndescribe Postal::MessageParser do\n  let(:server) { create(:server) }\n\n  it \"should not do anything when there are no tracking domains\" do\n    expect(server.track_domains.size).to eq 0\n    message = create_plain_text_message(server, \"Hello world!\", \"test@example.com\")\n    parser = Postal::MessageParser.new(message)\n    expect(parser.actioned?).to be false\n    expect(parser.tracked_links).to eq 0\n    expect(parser.tracked_images).to eq 0\n  end\n\n  it \"should replace links in messages\" do\n    message = create_plain_text_message(server, \"Hello world! http://github.com/atech/postal\", \"test@example.com\")\n    create(:track_domain, server: server, domain: message.domain)\n    parser = Postal::MessageParser.new(message)\n    expect(parser.actioned?).to be true\n    expect(parser.new_body).to match(/^Hello world! https:\\/\\/click\\.#{message.domain.name}/)\n    expect(parser.tracked_links).to eq 1\n  end\nend\n"
  },
  {
    "path": "spec/lib/postal/signer_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\nmodule Postal\n\n  RSpec.describe Signer do\n    STATIC_PRIVATE_KEY = OpenSSL::PKey::RSA.new(2048) # rubocop:disable Lint/ConstantDefinitionInBlock\n\n    subject(:signer) { described_class.new(STATIC_PRIVATE_KEY) }\n\n    describe \"#private_key\" do\n      it \"returns the private key\" do\n        expect(signer.private_key).to eq(STATIC_PRIVATE_KEY)\n      end\n    end\n\n    describe \"#public_key\" do\n      it \"returns the public key\" do\n        expect(signer.public_key.to_s).to eq(STATIC_PRIVATE_KEY.public_key.to_s)\n      end\n    end\n\n    describe \"#sign\" do\n      it \"returns a valid signature\" do\n        data = \"hello world!\"\n        signature = signer.sign(data)\n        expect(signature).to be_a(String)\n        verification = STATIC_PRIVATE_KEY.public_key.verify(OpenSSL::Digest.new(\"SHA256\"),\n                                                            signature,\n                                                            data)\n        expect(verification).to be true\n      end\n    end\n\n    describe \"#sign64\" do\n      it \"returns a valid Base64-encoded signature\" do\n        data = \"hello world!\"\n        signature = signer.sign64(data)\n        expect(signature).to be_a(String)\n        verification = STATIC_PRIVATE_KEY.public_key.verify(OpenSSL::Digest.new(\"SHA256\"),\n                                                            Base64.strict_decode64(signature),\n                                                            data)\n        expect(verification).to be true\n      end\n    end\n\n    describe \"#jwk\" do\n      it \"returns a valid JWK\" do\n        jwk = signer.jwk\n        expect(jwk).to be_a(JWT::JWK::RSA)\n      end\n    end\n\n    describe \"#sha1_sign\" do\n      it \"returns a valid signature\" do\n        data = \"hello world!\"\n        signature = signer.sha1_sign(data)\n        expect(signature).to be_a(String)\n        verification = STATIC_PRIVATE_KEY.public_key.verify(OpenSSL::Digest.new(\"SHA1\"),\n                                                            signature,\n                                                            data)\n        expect(verification).to be true\n      end\n    end\n\n    describe \"#sha1_sign64\" do\n      it \"returns a valid Base64-encoded signature\" do\n        data = \"hello world!\"\n        signature = signer.sha1_sign64(data)\n        expect(signature).to be_a(String)\n        verification = STATIC_PRIVATE_KEY.public_key.verify(OpenSSL::Digest.new(\"SHA1\"),\n                                                            Base64.strict_decode64(signature),\n                                                            data)\n        expect(verification).to be true\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/postal_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nRSpec.describe Postal do\n  describe \"#signer\" do\n    it \"returns a signer with the installation's signing key\" do\n      expect(Postal.signer).to be_a(Postal::Signer)\n      expect(Postal.signer.private_key.to_pem).to eq OpenSSL::PKey::RSA.new(File.read(Postal::Config.postal.signing_key_path)).to_pem\n    end\n  end\n\n  describe \"#change_database_connection_pool_size\" do\n    it \"changes the connection pool size\" do\n      expect { Postal.change_database_connection_pool_size(8) }.to change { ActiveRecord::Base.connection_pool.size }.from(5).to(8)\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/query_string_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\ndescribe QueryString do\n  it \"works with a single item\" do\n    qs = described_class.new(\"to: test@example.com\")\n    expect(qs.hash[\"to\"]).to eq \"test@example.com\"\n  end\n\n  it \"works with a multiple items\" do\n    qs = described_class.new(\"to: test@example.com from: another@example.com\")\n    expect(qs.hash[\"to\"]).to eq \"test@example.com\"\n    expect(qs.hash[\"from\"]).to eq \"another@example.com\"\n  end\n\n  it \"does not require a space after the field name\" do\n    qs = described_class.new(\"to:test@example.com from:another@example.com\")\n    expect(qs.hash[\"to\"]).to eq \"test@example.com\"\n    expect(qs.hash[\"from\"]).to eq \"another@example.com\"\n  end\n\n  it \"returns nil when it receives blank\" do\n    qs = described_class.new(\"to:[blank]\")\n    expect(qs.hash[\"to\"]).to eq nil\n  end\n\n  it \"handles dates with spaces\" do\n    qs = described_class.new(\"date: 2017-02-12 15:20\")\n    expect(qs.hash[\"date\"]).to eq(\"2017-02-12 15:20\")\n  end\n\n  it \"returns an array for multiple items\" do\n    qs = described_class.new(\"to: test@example.com to: another@example.com\")\n    expect(qs.hash[\"to\"]).to be_a(Array)\n    expect(qs.hash[\"to\"][0]).to eq \"test@example.com\"\n    expect(qs.hash[\"to\"][1]).to eq \"another@example.com\"\n  end\n\n  it \"works with a z in the string\" do\n    qs = described_class.new(\"to: testaz@example.com\")\n    expect(qs.hash[\"to\"]).to eq \"testaz@example.com\"\n  end\nend\n"
  },
  {
    "path": "spec/lib/received_header_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\ndescribe ReceivedHeader do\n  before do\n    allow(DNSResolver.local).to receive(:ip_to_hostname).and_return(\"hostname.com\")\n  end\n\n  describe \".generate\" do\n    context \"when server is nil\" do\n      it \"returns the correct string\" do\n        result = described_class.generate(nil, \"testhelo\", \"1.1.1.1\", :smtp)\n        expect(result).to eq \"from testhelo (hostname.com [1.1.1.1]) \" \\\n                             \"by #{Postal::Config.postal.smtp_hostname} \" \\\n                             \"with SMTP; #{Time.now.utc.rfc2822}\"\n      end\n    end\n\n    context \"when server is provided with privacy_mode=true\" do\n      it \"returns the correct string\" do\n        server = Server.new(privacy_mode: true)\n        result = described_class.generate(server, \"testhelo\", \"1.1.1.1\", :smtp)\n        expect(result).to eq \"by #{Postal::Config.postal.smtp_hostname} \" \\\n                             \"with SMTP; #{Time.now.utc.rfc2822}\"\n      end\n    end\n\n    context \"when server is provided with privacy_mode=false\" do\n      it \"returns the correct string\" do\n        server = Server.new(privacy_mode: false)\n        result = described_class.generate(server, \"testhelo\", \"1.1.1.1\", :smtp)\n        expect(result).to eq \"from testhelo (hostname.com [1.1.1.1]) \" \\\n                             \"by #{Postal::Config.postal.smtp_hostname} \" \\\n                             \"with SMTP; #{Time.now.utc.rfc2822}\"\n      end\n    end\n\n    context \"when type is http\" do\n      it \"returns the correct string\" do\n        result = described_class.generate(nil, \"web-ui\", \"1.1.1.1\", :http)\n        expect(result).to eq \"from web-ui (hostname.com [1.1.1.1]) \" \\\n                             \"by #{Postal::Config.postal.web_hostname} \" \\\n                             \"with HTTP; #{Time.now.utc.rfc2822}\"\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/smtp_client/endpoint_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule SMTPClient\n\n  RSpec.describe Endpoint do\n    let(:ssl_mode) { SSLModes::AUTO }\n    let(:server) { Server.new(\"mx1.example.com\", port: 25, ssl_mode: ssl_mode) }\n    let(:ip) { \"1.2.3.4\" }\n\n    before do\n      allow(Net::SMTP).to receive(:new).and_wrap_original do |original_method, *args|\n        smtp = original_method.call(*args)\n        allow(smtp).to receive(:start)\n        allow(smtp).to receive(:started?).and_return(true)\n        allow(smtp).to receive(:send_message)\n        allow(smtp).to receive(:finish)\n        smtp\n      end\n    end\n\n    subject(:endpoint) { described_class.new(server, ip) }\n\n    describe \"#description\" do\n      it \"returns a description for the endpoint\" do\n        expect(endpoint.description).to eq \"1.2.3.4:25 (mx1.example.com)\"\n      end\n    end\n\n    describe \"#ipv6?\" do\n      context \"when the IP address is an IPv6 address\" do\n        let(:ip) { \"2a00:67a0:a::1\" }\n\n        it \"returns true\" do\n          expect(endpoint.ipv6?).to be true\n        end\n      end\n\n      context \"when the IP address is an IPv4 address\" do\n        it \"returns false\" do\n          expect(endpoint.ipv6?).to be false\n        end\n      end\n    end\n\n    describe \"#ipv4?\" do\n      context \"when the IP address is an IPv4 address\" do\n        it \"returns true\" do\n          expect(endpoint.ipv4?).to be true\n        end\n      end\n\n      context \"when the IP address is an IPv6 address\" do\n        let(:ip) { \"2a00:67a0:a::1\" }\n\n        it \"returns false\" do\n          expect(endpoint.ipv4?).to be false\n        end\n      end\n    end\n\n    describe \"#start_smtp_session\" do\n      context \"when given no source IP address\" do\n        it \"creates a new Net::SMTP client with appropriate details\" do\n          client = endpoint.start_smtp_session\n          expect(client.address).to eq \"1.2.3.4\"\n        end\n\n        it \"sets the appropriate timeouts from the config\" do\n          client = endpoint.start_smtp_session\n          expect(client.open_timeout).to eq Postal::Config.smtp_client.open_timeout\n          expect(client.read_timeout).to eq Postal::Config.smtp_client.read_timeout\n        end\n\n        it \"does not set a source address\" do\n          client = endpoint.start_smtp_session\n          expect(client.source_address).to be_nil\n        end\n\n        it \"sets the TLS hostname\" do\n          client = endpoint.start_smtp_session\n          expect(client.tls_hostname).to eq \"mx1.example.com\"\n        end\n\n        it \"starts the SMTP client the default HELO\" do\n          endpoint.start_smtp_session\n          expect(endpoint.smtp_client).to have_received(:start).with(Postal::Config.postal.smtp_hostname)\n        end\n\n        context \"when the SSL mode is Auto\" do\n          it \"enables STARTTLS auto \" do\n            client = endpoint.start_smtp_session\n            expect(client.starttls?).to eq :auto\n          end\n        end\n\n        context \"when the SSL mode is STARTLS\" do\n          let(:ssl_mode) { SSLModes::STARTTLS }\n\n          it \"as starttls as always\" do\n            client = endpoint.start_smtp_session\n            expect(client.starttls?).to eq :always\n          end\n        end\n\n        context \"when the SSL mode is TLS\" do\n          let(:ssl_mode) { SSLModes::TLS }\n\n          it \"as starttls as always\" do\n            client = endpoint.start_smtp_session\n            expect(client.tls?).to be true\n          end\n        end\n\n        context \"when the SSL mode is None\" do\n          let(:ssl_mode) { SSLModes::NONE }\n\n          it \"disables STARTTLS and TLS\" do\n            client = endpoint.start_smtp_session\n            expect(client.starttls?).to be false\n            expect(client.tls?).to be false\n          end\n        end\n\n        context \"when the SSL mode is Auto but ssl_allow is false\" do\n          it \"disables STARTTLS and TLS\" do\n            client = endpoint.start_smtp_session(allow_ssl: false)\n            expect(client.starttls?).to be false\n            expect(client.tls?).to be false\n          end\n        end\n      end\n\n      context \"when given a source IP address\" do\n        let(:ip_address) { create(:ip_address) }\n\n        context \"when the endpoint IP is ipv4\" do\n          it \"sets the source address to the IPv4 address\" do\n            client = endpoint.start_smtp_session(source_ip_address: ip_address)\n            expect(client.source_address).to eq ip_address.ipv4\n          end\n        end\n\n        context \"when the endpoint IP is ipv6\" do\n          let(:ip) { \"2a00:67a0:a::1\" }\n\n          it \"sets the source address to the IPv6 address\" do\n            client = endpoint.start_smtp_session(source_ip_address: ip_address)\n            expect(client.source_address).to eq ip_address.ipv6\n          end\n        end\n\n        it \"starts the SMTP client with the IP addresses hostname\" do\n          endpoint.start_smtp_session(source_ip_address: ip_address)\n          expect(endpoint.smtp_client).to have_received(:start).with(ip_address.hostname)\n        end\n      end\n    end\n\n    describe \"#send_message\" do\n      context \"when the smtp client has not been created\" do\n        it \"raises an error\" do\n          expect { endpoint.send_message(\"\", \"\", \"\") }.to raise_error Endpoint::SMTPSessionNotStartedError\n        end\n      end\n\n      context \"when the smtp client exists but is not started\" do\n        it \"raises an error\" do\n          endpoint.start_smtp_session\n          expect(endpoint.smtp_client).to receive(:started?).and_return(false)\n          expect { endpoint.send_message(\"\", \"\", \"\") }.to raise_error Endpoint::SMTPSessionNotStartedError\n        end\n      end\n\n      context \"when the smtp client is started\" do\n        before do\n          endpoint.start_smtp_session\n        end\n\n        it \"resets any previous errors\" do\n          expect(endpoint.smtp_client).to receive(:rset_errors)\n          endpoint.send_message(\"test message\", \"from@example.com\", \"to@example.com\")\n        end\n\n        it \"sends the message to the SMTP client\" do\n          endpoint.send_message(\"test message\", \"from@example.com\", \"to@example.com\")\n          expect(endpoint.smtp_client).to have_received(:send_message).with(\"test message\", \"from@example.com\", [\"to@example.com\"])\n        end\n\n        context \"when the connection is reset during sending\" do\n          before do\n            endpoint.start_smtp_session\n            allow(endpoint.smtp_client).to receive(:send_message) do\n              raise Errno::ECONNRESET\n            end\n          end\n\n          it \"closes the SMTP client\" do\n            expect(endpoint).to receive(:finish_smtp_session).and_call_original\n            endpoint.send_message(\"test message\", \"\", \"\")\n          end\n\n          it \"retries sending the message once\" do\n            expect(endpoint).to receive(:send_message).twice.and_call_original\n            endpoint.send_message(\"test message\", \"\", \"\")\n          end\n\n          context \"if the retry also fails\" do\n            it \"raises the error\" do\n              allow(endpoint).to receive(:send_message).and_raise(Errno::ECONNRESET)\n              expect { endpoint.send_message(\"test message\", \"\", \"\") }.to raise_error(Errno::ECONNRESET)\n            end\n          end\n        end\n      end\n    end\n\n    describe \"#reset_smtp_session\" do\n      it \"calls rset on the client\" do\n        endpoint.start_smtp_session\n        expect(endpoint.smtp_client).to receive(:rset)\n        endpoint.reset_smtp_session\n      end\n\n      context \"if there is an error\" do\n        it \"finishes the smtp client\" do\n          endpoint.start_smtp_session\n          allow(endpoint.smtp_client).to receive(:rset).and_raise(StandardError)\n          expect(endpoint).to receive(:finish_smtp_session)\n          endpoint.reset_smtp_session\n        end\n      end\n    end\n\n    describe \"#finish_smtp_session\" do\n      it \"calls finish on the client\" do\n        endpoint.start_smtp_session\n        expect(endpoint.smtp_client).to receive(:finish)\n        endpoint.finish_smtp_session\n      end\n\n      it \"sets the smtp client to nil\" do\n        endpoint.start_smtp_session\n        endpoint.finish_smtp_session\n        expect(endpoint.smtp_client).to be_nil\n      end\n\n      context \"if the client finish raises an error\" do\n        it \"does not raise it\" do\n          endpoint.start_smtp_session\n          allow(endpoint.smtp_client).to receive(:finish).and_raise(StandardError)\n          expect { endpoint.finish_smtp_session }.not_to raise_error\n        end\n      end\n    end\n\n    describe \".default_helo_hostname\" do\n      context \"when the configuration specifies a helo hostname\" do\n        before do\n          allow(Postal::Config.dns).to receive(:helo_hostname).and_return(\"helo.example.com\")\n        end\n\n        it \"returns that\" do\n          expect(described_class.default_helo_hostname).to eq \"helo.example.com\"\n        end\n      end\n\n      context \"when the configuration does not specify a helo hostname but has an smtp hostname\" do\n        before do\n          allow(Postal::Config.dns).to receive(:helo_hostname).and_return(nil)\n          allow(Postal::Config.postal).to receive(:smtp_hostname).and_return(\"smtp.example.com\")\n        end\n\n        it \"returns the smtp hostname\" do\n          expect(described_class.default_helo_hostname).to eq \"smtp.example.com\"\n        end\n      end\n\n      context \"when the configuration has neither a helo hostname or an smtp hostname\" do\n        before do\n          allow(Postal::Config.dns).to receive(:helo_hostname).and_return(nil)\n          allow(Postal::Config.postal).to receive(:smtp_hostname).and_return(nil)\n        end\n\n        it \"returns localhost\" do\n          expect(described_class.default_helo_hostname).to eq \"localhost\"\n        end\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/smtp_client/server_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule SMTPClient\n\n  RSpec.describe Server do\n    let(:hostname) { \"example.com\" }\n    let(:port) { 25 }\n    let(:ssl_mode) { SSLModes::AUTO }\n\n    subject(:server) { described_class.new(hostname, port: port, ssl_mode: ssl_mode) }\n\n    describe \"#endpoints\" do\n      context \"when there are A and AAAA records\" do\n        before do\n          allow(DNSResolver.local).to receive(:a).and_return([\"1.2.3.4\", \"2.3.4.5\"])\n          allow(DNSResolver.local).to receive(:aaaa).and_return([\"2a00::67a0:a::1234\", \"2a00::67a0:a::2345\"])\n        end\n\n        it \"asks the resolver for the A and AAAA records for the hostname\" do\n          server.endpoints\n          expect(DNSResolver.local).to have_received(:a).with(hostname).once\n          expect(DNSResolver.local).to have_received(:aaaa).with(hostname).once\n        end\n\n        it \"returns endpoints for ipv6 addresses followed by ipv4\" do\n          expect(server.endpoints).to match [\n            have_attributes(ip_address: \"2a00::67a0:a::1234\"),\n            have_attributes(ip_address: \"2a00::67a0:a::2345\"),\n            have_attributes(ip_address: \"1.2.3.4\"),\n            have_attributes(ip_address: \"2.3.4.5\"),\n          ]\n        end\n      end\n\n      context \"when there are just A records\" do\n        before do\n          allow(DNSResolver.local).to receive(:a).and_return([\"1.2.3.4\", \"2.3.4.5\"])\n          allow(DNSResolver.local).to receive(:aaaa).and_return([])\n        end\n\n        it \"returns ipv4 endpoints\" do\n          expect(server.endpoints).to match [\n            have_attributes(ip_address: \"1.2.3.4\"),\n            have_attributes(ip_address: \"2.3.4.5\"),\n          ]\n        end\n      end\n\n      context \"when there are just AAAA records\" do\n        before do\n          allow(DNSResolver.local).to receive(:a).and_return([])\n          allow(DNSResolver.local).to receive(:aaaa).and_return([\"2a00::67a0:a::1234\", \"2a00::67a0:a::2345\"])\n        end\n\n        it \"returns ipv6 endpoints\" do\n          expect(server.endpoints).to match [\n            have_attributes(ip_address: \"2a00::67a0:a::1234\"),\n            have_attributes(ip_address: \"2a00::67a0:a::2345\"),\n          ]\n        end\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/smtp_server/client/auth_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule SMTPServer\n\n  describe Client do\n    let(:ip_address) { \"1.2.3.4\" }\n    subject(:client) { described_class.new(ip_address) }\n\n    before do\n      client.handle(\"HELO test.example.com\")\n    end\n\n    describe \"AUTH PLAIN\" do\n      context \"when no credentials are provided on the initial data\" do\n        it \"returns a 334\" do\n          expect(client.handle(\"AUTH PLAIN\")).to eq(\"334\")\n        end\n\n        it \"accepts the username and password from the next input\" do\n          client.handle(\"AUTH PLAIN\")\n          credential = create(:credential, type: \"SMTP\")\n          expect(client.handle(credential.to_smtp_plain)).to match(/235 Granted for/)\n        end\n      end\n\n      context \"when valid credentials are provided on one line\" do\n        it \"authenticates and returns a response\" do\n          credential = create(:credential, type: \"SMTP\")\n          expect(client.handle(\"AUTH PLAIN #{credential.to_smtp_plain}\")).to match(/235 Granted for/)\n          expect(client.credential).to eq credential\n        end\n      end\n\n      context \"when invalid credentials are provided\" do\n        it \"returns an error and resets the state\" do\n          base64 = Base64.encode64(\"user\\0pass\")\n          expect(client.handle(\"AUTH PLAIN #{base64}\")).to eq(\"535 Invalid credential\")\n          expect(client.state).to eq :welcomed\n        end\n      end\n\n      context \"when username or password is missing\" do\n        it \"returns an error and resets the state\" do\n          base64 = Base64.encode64(\"pass\")\n          expect(client.handle(\"AUTH PLAIN #{base64}\")).to eq(\"535 Authenticated failed - protocol error\")\n          expect(client.state).to eq :welcomed\n        end\n      end\n    end\n\n    describe \"AUTH LOGIN\" do\n      context \"when no username is provided on the first line\" do\n        it \"requests the username\" do\n          expect(client.handle(\"AUTH LOGIN\")).to eq(\"334 VXNlcm5hbWU6\")\n        end\n\n        it \"requests a password after a username\" do\n          client.handle(\"AUTH LOGIN\")\n          expect(client.handle(\"xx\")).to eq(\"334 UGFzc3dvcmQ6\")\n        end\n\n        it \"authenticates and returns a response if the password is correct\" do\n          client.handle(\"AUTH LOGIN\")\n          client.handle(\"xx\")\n          credential = create(:credential, type: \"SMTP\")\n          password = Base64.encode64(credential.key)\n          expect(client.handle(password)).to match(/235 Granted for/)\n        end\n\n        it \"returns an error when an invalid credential is provided\" do\n          client.handle(\"AUTH LOGIN\")\n          client.handle(\"xx\")\n          password = Base64.encode64(\"xx\")\n          expect(client.handle(password)).to eq(\"535 Invalid credential\")\n        end\n      end\n\n      context \"when a username is provided on the first line\" do\n        it \"requests a password\" do\n          username = Base64.encode64(\"xx\")\n          expect(client.handle(\"AUTH LOGIN #{username}\")).to eq(\"334 UGFzc3dvcmQ6\")\n        end\n\n        it \"authenticates and returns a response\" do\n          credential = create(:credential, type: \"SMTP\")\n          username = Base64.encode64(\"xx\")\n          password = Base64.encode64(credential.key)\n          expect(client.handle(\"AUTH LOGIN #{username}\")).to eq(\"334 UGFzc3dvcmQ6\")\n          expect(client.handle(password)).to match(/235 Granted for/)\n          expect(client.credential).to eq credential\n        end\n\n        it \"returns an error and resets the state\" do\n          username = Base64.encode64(\"xx\")\n          password = Base64.encode64(\"xx\")\n          expect(client.handle(\"AUTH LOGIN #{username}\")).to eq(\"334 UGFzc3dvcmQ6\")\n          expect(client.handle(password)).to eq(\"535 Invalid credential\")\n          expect(client.state).to eq :welcomed\n        end\n      end\n    end\n\n    describe \"AUTH CRAM-MD5\" do\n      context \"when valid credentials are provided\" do\n        it \"authenticates and returns a response\" do\n          credential = create(:credential, type: \"SMTP\")\n          result = client.handle(\"AUTH CRAM-MD5\")\n          expect(result).to match(/\\A334 [A-Za-z0-9=]+\\z/)\n          challenge = Base64.decode64(result.split[1])\n          password = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new(\"md5\"), credential.key, challenge)\n          base64 = Base64.encode64(\"#{credential.server.organization.permalink}/#{credential.server.permalink} #{password}\")\n          expect(client.handle(base64)).to match(/235 Granted for/)\n          expect(client.credential).to eq credential\n        end\n      end\n\n      context \"when no org/server matches the provided username\" do\n        it \"returns an error\" do\n          client.handle(\"AUTH CRAM-MD5\")\n          base64 = Base64.encode64(\"org/server password\")\n          expect(client.handle(base64)).to eq \"535 Denied\"\n        end\n      end\n\n      context \"when invalid credentials are provided\" do\n        it \"returns an error and resets the state\" do\n          server = create(:server)\n          base64 = Base64.encode64(\"#{server.organization.permalink}/#{server.permalink} invalid-password\")\n          client.handle(\"AUTH CRAM-MD5\")\n          expect(client.handle(base64)).to eq(\"535 Denied\")\n        end\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/smtp_server/client/data_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule SMTPServer\n\n  describe Client do\n    let(:ip_address) { \"1.2.3.4\" }\n    subject(:client) { described_class.new(ip_address) }\n\n    describe \"DATA\" do\n      it \"returns an error if no helo\" do\n        expect(client.handle(\"DATA\")).to eq \"503 HELO/EHLO, MAIL FROM and RCPT TO before sending data\"\n      end\n\n      it \"returns an error if no mail from\" do\n        client.handle(\"HELO test.example.com\")\n        expect(client.handle(\"DATA\")).to eq \"503 HELO/EHLO, MAIL FROM and RCPT TO before sending data\"\n      end\n\n      it \"returns an error if no rcpt to\" do\n        client.handle(\"HELO test.example.com\")\n        client.handle(\"MAIL FROM: test@example.com\")\n        expect(client.handle(\"DATA\")).to eq \"503 HELO/EHLO, MAIL FROM and RCPT TO before sending data\"\n      end\n\n      it \"returns go ahead\" do\n        route = create(:route)\n        client.handle(\"HELO test.example.com\")\n        client.handle(\"MAIL FROM: test@test.com\")\n        client.handle(\"RCPT TO: #{route.name}@#{route.domain.name}\")\n        expect(client.handle(\"DATA\")).to eq \"354 Go ahead\"\n      end\n\n      it \"adds a received header for itself\" do\n        route = create(:route)\n        client.handle(\"HELO test.example.com\")\n        client.handle(\"MAIL FROM: test@test.com\")\n        client.handle(\"RCPT TO: #{route.name}@#{route.domain.name}\")\n        Timecop.freeze do\n          client.handle(\"DATA\")\n          expect(client.headers[\"received\"]).to include \"from test.example.com (1.2.3.4 [1.2.3.4]) by #{Postal::Config.postal.smtp_hostname} with SMTP; #{Time.now.utc.rfc2822}\"\n        end\n      end\n\n      describe \"subsequent commands\" do\n        let(:route) { create(:route) }\n        before do\n          client.handle(\"HELO test.example.com\")\n          client.handle(\"MAIL FROM: test@test.com\")\n          client.handle(\"RCPT TO: #{route.name}@#{route.domain.name}\")\n        end\n\n        it \"logs headers\" do\n          client.handle(\"DATA\")\n          client.handle(\"Subject: Test\")\n          client.handle(\"From: test@test.com\")\n          client.handle(\"To: test1@example.com\")\n          client.handle(\"To: test2@example.com\")\n          client.handle(\"X-Something: abcdef1234\")\n          client.handle(\"X-Multiline: 1234\")\n          client.handle(\"             4567\")\n          expect(client.headers[\"subject\"]).to eq [\"Test\"]\n          expect(client.headers[\"from\"]).to eq [\"test@test.com\"]\n          expect(client.headers[\"to\"]).to eq [\"test1@example.com\", \"test2@example.com\"]\n          expect(client.headers[\"x-something\"]).to eq [\"abcdef1234\"]\n          expect(client.headers[\"x-multiline\"]).to eq [\"1234             4567\"]\n        end\n\n        it \"logs content\" do\n          Timecop.freeze do\n            client.handle(\"DATA\")\n            client.handle(\"Subject: Test\")\n            client.handle(\"\")\n            client.handle(\"This is some content for the message.\")\n            client.handle(\"It will keep going.\")\n            expect(client.instance_variable_get(\"@data\")).to eq <<~DATA\n              Received: from test.example.com (1.2.3.4 [1.2.3.4]) by #{Postal::Config.postal.smtp_hostname} with SMTP; #{Time.now.utc.rfc2822}\\r\n              Subject: Test\\r\n              \\r\n              This is some content for the message.\\r\n              It will keep going.\\r\n            DATA\n          end\n        end\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/smtp_server/client/finished_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule SMTPServer\n\n  describe Client do\n    let(:ip_address) { \"1.2.3.4\" }\n    let(:server) { create(:server) }\n    subject(:client) { described_class.new(ip_address) }\n\n    let(:credential) { create(:credential, server: server, type: \"SMTP\") }\n    let(:auth_plain) { credential&.to_smtp_plain }\n    let(:mail_from) { \"test@example.com\" }\n    let(:rcpt_to) { \"test@example.com\" }\n\n    before do\n      client.handle(\"HELO test.example.com\")\n      client.handle(\"AUTH PLAIN #{auth_plain}\") if auth_plain\n      client.handle(\"MAIL FROM: #{mail_from}\")\n      client.handle(\"RCPT TO: #{rcpt_to}\")\n    end\n\n    describe \"when finished sending data\" do\n      context \"when the . character does not end with a <CR>\" do\n        it \"does nothing\" do\n          allow(Postal::Config.smtp_server).to receive(:max_message_size).and_return(1)\n          client.handle(\"DATA\")\n          client.handle(\"Subject: Hello\")\n          client.handle(\"\\r\")\n          expect(client.handle(\".\")).to be nil\n        end\n      end\n\n      context \"when the data before the . character does not end with a <CR>\" do\n        it \"does nothing\" do\n          allow(Postal::Config.smtp_server).to receive(:max_message_size).and_return(1)\n          client.handle(\"DATA\")\n          client.handle(\"Subject: Hello\")\n          expect(client.handle(\".\\r\")).to be nil\n        end\n      end\n\n      context \"when the data is larger than the maximum message size\" do\n        it \"returns an error and resets the state\" do\n          allow(Postal::Config.smtp_server).to receive(:max_message_size).and_return(1)\n          client.handle(\"DATA\")\n          client.handle(\"a\" * 1024 * 1024 * 10)\n          client.handle(\"\\r\")\n          expect(client.handle(\".\\r\")).to eq \"552 Message too large (maximum size 1MB)\"\n        end\n      end\n\n      context \"when a loop is detected\" do\n        it \"returns an error and resets the state\" do\n          client.handle(\"DATA\")\n          client.handle(\"Received: from example1.com by #{Postal::Config.postal.smtp_hostname}\")\n          client.handle(\"Received: from example2.com by #{Postal::Config.postal.smtp_hostname}\")\n          client.handle(\"Received: from example1.com by #{Postal::Config.postal.smtp_hostname}\")\n          client.handle(\"Received: from example2.com by #{Postal::Config.postal.smtp_hostname}\")\n          client.handle(\"Subject: Test\")\n          client.handle(\"From: #{mail_from}\")\n          client.handle(\"To: #{rcpt_to}\")\n          client.handle(\"\")\n          client.handle(\"This is a test message\")\n          client.handle(\"\\r\")\n          expect(client.handle(\".\\r\")).to eq \"550 Loop detected\"\n        end\n      end\n\n      context \"when the email content is not suitable for the credential\" do\n        it \"returns an error and resets the state\" do\n          client.handle(\"DATA\")\n          client.handle(\"Subject: Test\")\n          client.handle(\"From: invalid@krystal.uk\")\n          client.handle(\"To: #{rcpt_to}\")\n          client.handle(\"\")\n          client.handle(\"This is a test message\")\n          client.handle(\"\\r\")\n          expect(client.handle(\".\\r\")).to eq \"530 From/Sender name is not valid\"\n        end\n      end\n\n      context \"when sending an outgoing email\" do\n        let(:domain) { create(:domain, owner: server) }\n        let(:mail_from) { \"test@#{domain.name}\" }\n        let(:auth_plain) { credential.to_smtp_plain }\n\n        it \"stores the message and resets the state\" do\n          client.handle(\"DATA\")\n          client.handle(\"Subject: Test\")\n          client.handle(\"From: #{mail_from}\")\n          client.handle(\"To: #{rcpt_to}\")\n          client.handle(\"\")\n          client.handle(\"This is a test message\")\n          client.handle(\"\\r\")\n          expect(client.handle(\".\\r\")).to eq \"250 OK\"\n          queued_message = QueuedMessage.first\n          expect(queued_message).to have_attributes(\n            domain: \"example.com\",\n            server: server\n          )\n\n          expect(server.message(queued_message.message_id)).to have_attributes(\n            mail_from: mail_from,\n            rcpt_to: rcpt_to,\n            subject: \"Test\",\n            scope: \"outgoing\",\n            route_id: nil,\n            credential_id: credential.id,\n            raw_headers: kind_of(String),\n            raw_message: kind_of(String)\n          )\n        end\n      end\n\n      context \"when sending a bounce message\" do\n        let(:credential) { nil }\n        let(:rcpt_to) { \"#{server.token}@#{Postal::Config.dns.return_path_domain}\" }\n\n        context \"when there is a return path route\" do\n          let(:domain) { create(:domain, owner: server) }\n\n          before do\n            endpoint = create(:http_endpoint, server: server)\n            create(:route, domain: domain, server: server, name: \"__returnpath__\", mode: \"Endpoint\", endpoint: endpoint)\n          end\n\n          it \"stores the message for the return path route and resets the state\" do\n            client.handle(\"DATA\")\n            client.handle(\"Subject: Bounce: Test\")\n            client.handle(\"From: #{mail_from}\")\n            client.handle(\"To: #{rcpt_to}\")\n            client.handle(\"\")\n            client.handle(\"This is a test message\")\n            client.handle(\"\\r\")\n            expect(client.handle(\".\\r\")).to eq \"250 OK\"\n\n            queued_message = QueuedMessage.first\n            expect(queued_message).to have_attributes(\n              domain: Postal::Config.dns.return_path_domain,\n              server: server\n            )\n\n            expect(server.message(queued_message.message_id)).to have_attributes(\n              mail_from: mail_from,\n              rcpt_to: rcpt_to,\n              subject: \"Bounce: Test\",\n              scope: \"incoming\",\n              route_id: server.routes.first.id,\n              domain_id: domain.id,\n              credential_id: nil,\n              raw_headers: kind_of(String),\n              raw_message: kind_of(String),\n              bounce: true\n            )\n          end\n        end\n\n        context \"when there is no return path route\" do\n          it \"stores the message normally and resets the state\" do\n            client.handle(\"DATA\")\n            client.handle(\"Subject: Bounce: Test\")\n            client.handle(\"From: #{mail_from}\")\n            client.handle(\"To: #{rcpt_to}\")\n            client.handle(\"\")\n            client.handle(\"This is a test message\")\n            client.handle(\"\\r\")\n            expect(client.handle(\".\\r\")).to eq \"250 OK\"\n\n            queued_message = QueuedMessage.first\n            expect(queued_message).to have_attributes(\n              domain: Postal::Config.dns.return_path_domain,\n              server: server\n            )\n\n            expect(server.message(queued_message.message_id)).to have_attributes(\n              mail_from: mail_from,\n              rcpt_to: rcpt_to,\n              subject: \"Bounce: Test\",\n              scope: \"incoming\",\n              route_id: nil,\n              domain_id: nil,\n              credential_id: nil,\n              raw_headers: kind_of(String),\n              raw_message: kind_of(String),\n              bounce: true\n            )\n          end\n        end\n      end\n\n      context \"when receiving an incoming email\" do\n        let(:domain) { create(:domain, owner: server) }\n        let(:route) { create(:route, server: server, domain: domain) }\n\n        let(:credential) { nil }\n        let(:rcpt_to) { \"#{route.name}@#{domain.name}\" }\n\n        it \"stores the message and resets the state\" do\n          client.handle(\"DATA\")\n          client.handle(\"Subject: Test\")\n          client.handle(\"From: #{mail_from}\")\n          client.handle(\"To: #{rcpt_to}\")\n          client.handle(\"\")\n          client.handle(\"This is a test message\")\n          client.handle(\"\\r\")\n          expect(client.handle(\".\\r\")).to eq \"250 OK\"\n\n          queued_message = QueuedMessage.first\n          expect(queued_message).to have_attributes(\n            domain: domain.name,\n            server: server\n          )\n\n          expect(server.message(queued_message.message_id)).to have_attributes(\n            mail_from: mail_from,\n            rcpt_to: rcpt_to,\n            subject: \"Test\",\n            scope: \"incoming\",\n            route_id: route.id,\n            domain_id: domain.id,\n            credential_id: nil,\n            raw_headers: kind_of(String),\n            raw_message: kind_of(String)\n          )\n        end\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/smtp_server/client/helo_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule SMTPServer\n\n  describe Client do\n    let(:ip_address) { \"1.2.3.4\" }\n    subject(:client) { described_class.new(ip_address) }\n\n    describe \"HELO\" do\n      it \"returns the hostname\" do\n        expect(client.state).to eq :welcome\n        expect(client.handle(\"HELO: test.example.com\")).to eq \"250 #{Postal::Config.postal.smtp_hostname}\"\n        expect(client.state).to eq :welcomed\n      end\n    end\n\n    describe \"EHLO\" do\n      it \"returns the capabilities\" do\n        expect(client.handle(\"EHLO test.example.com\")).to eq [\"250-My capabilities are\",\n                                                              \"250 AUTH CRAM-MD5 PLAIN LOGIN\",]\n      end\n\n      context \"when TLS is enabled\" do\n        it \"returns capabilities include starttls\" do\n          allow(Postal::Config.smtp_server).to receive(:tls_enabled?).and_return(true)\n          expect(client.handle(\"EHLO test.example.com\")).to eq [\"250-My capabilities are\",\n                                                                \"250-STARTTLS\",\n                                                                \"250 AUTH CRAM-MD5 PLAIN LOGIN\",]\n        end\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/smtp_server/client/mail_from_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule SMTPServer\n\n  describe Client do\n    let(:ip_address) { \"1.2.3.4\" }\n    subject(:client) { described_class.new(ip_address) }\n\n    describe \"MAIL FROM\" do\n      it \"returns an error if no HELO is provided\" do\n        expect(client.handle(\"MAIL FROM: test@example.com\")).to eq \"503 EHLO/HELO first please\"\n        expect(client.state).to eq :welcome\n      end\n\n      it \"resets the transaction when called\" do\n        expect(client).to receive(:transaction_reset).and_call_original.at_least(3).times\n        client.handle(\"HELO test.example.com\")\n        client.handle(\"MAIL FROM: test@example.com\")\n        client.handle(\"MAIL FROM: test2@example.com\")\n      end\n\n      it \"sets the mail from address\" do\n        client.handle(\"HELO test.example.com\")\n        expect(client.handle(\"MAIL FROM: test@example.com\")).to eq \"250 OK\"\n        expect(client.state).to eq :mail_from_received\n        expect(client.instance_variable_get(\"@mail_from\")).to eq \"test@example.com\"\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/smtp_server/client/proxy_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule SMTPServer\n\n  describe Client do\n    let(:ip_address) { nil }\n    subject(:client) { described_class.new(ip_address) }\n\n    describe \"PROXY\" do\n      context \"when the proxy header is sent correctly\" do\n        it \"sets the IP address\" do\n          expect(client.handle(\"PROXY TCP4 1.1.1.1 2.2.2.2 1111 2222\")).to eq \"220 #{Postal::Config.postal.smtp_hostname} ESMTP Postal/#{client.trace_id}\"\n          expect(client.ip_address).to eq \"1.1.1.1\"\n        end\n      end\n\n      context \"when the proxy header is not valid\" do\n        it \"returns an error\" do\n          expect(client.handle(\"PROXY TCP4\")).to eq \"502 Proxy Error\"\n          expect(client.finished?).to be true\n        end\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/smtp_server/client/rcpt_to_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule SMTPServer\n\n  describe Client do\n    let(:ip_address) { \"1.2.3.4\" }\n    subject(:client) { described_class.new(ip_address) }\n\n    describe \"RCPT TO\" do\n      let(:helo) { \"test.example.com\" }\n      let(:mail_from) { \"test@example.com\" }\n\n      before do\n        client.handle(\"HELO #{helo}\")\n        client.handle(\"MAIL FROM: #{mail_from}\") if mail_from\n      end\n\n      context \"when MAIL FROM has not been sent\" do\n        let(:mail_from) { nil }\n\n        it \"returns an error if RCPT TO is sent before MAIL FROM\" do\n          expect(client.handle(\"RCPT TO: no-route-here@internal.com\")).to eq \"503 EHLO/HELO and MAIL FROM first please\"\n          expect(client.state).to eq :welcomed\n        end\n      end\n\n      it \"returns an error if RCPT TO is not valid\" do\n        expect(client.handle(\"RCPT TO: blah\")).to eq \"501 Invalid RCPT TO\"\n      end\n\n      it \"returns an error if RCPT TO is empty\" do\n        expect(client.handle(\"RCPT TO: \")).to eq \"501 RCPT TO should not be empty\"\n      end\n\n      context \"when the RCPT TO address is the system return path host\" do\n        it \"returns an error if the server does not exist\" do\n          expect(client.handle(\"RCPT TO: nothing@#{Postal::Config.dns.return_path_domain}\")).to eq \"550 Invalid server token\"\n        end\n\n        it \"returns an error if the server is suspended\" do\n          server = create(:server, :suspended)\n          expect(client.handle(\"RCPT TO: #{server.token}@#{Postal::Config.dns.return_path_domain}\"))\n            .to eq \"535 Mail server has been suspended\"\n        end\n\n        it \"adds a recipient if all OK\" do\n          server = create(:server)\n          address = \"#{server.token}@#{Postal::Config.dns.return_path_domain}\"\n          expect(client.handle(\"RCPT TO: #{address}\")).to eq \"250 OK\"\n          expect(client.recipients).to eq [[:bounce, address, server]]\n          expect(client.state).to eq :rcpt_to_received\n        end\n      end\n\n      context \"when the RCPT TO address is on a host using the return path prefix\" do\n        it \"returns an error if the server does not exist\" do\n          address = \"nothing@#{Postal::Config.dns.custom_return_path_prefix}.example.com\"\n          expect(client.handle(\"RCPT TO: #{address}\")).to eq \"550 Invalid server token\"\n        end\n\n        it \"returns an error if the server is suspended\" do\n          server = create(:server, :suspended)\n          address = \"#{server.token}@#{Postal::Config.dns.custom_return_path_prefix}.example.com\"\n          expect(client.handle(\"RCPT TO: #{address}\")).to eq \"535 Mail server has been suspended\"\n        end\n\n        it \"adds a recipient if all OK\" do\n          server = create(:server)\n          address = \"#{server.token}@#{Postal::Config.dns.custom_return_path_prefix}.example.com\"\n          expect(client.handle(\"RCPT TO: #{address}\")).to eq \"250 OK\"\n          expect(client.recipients).to eq [[:bounce, address, server]]\n          expect(client.state).to eq :rcpt_to_received\n        end\n      end\n\n      context \"when the RCPT TO address is within the route domain\" do\n        it \"returns an error if the route token is invalid\" do\n          address = \"nothing@#{Postal::Config.dns.route_domain}\"\n          expect(client.handle(\"RCPT TO: #{address}\")).to eq \"550 Invalid route token\"\n        end\n\n        it \"returns an error if the server is suspended\" do\n          server = create(:server, :suspended)\n          route = create(:route, server: server)\n          address = \"#{route.token}@#{Postal::Config.dns.route_domain}\"\n          expect(client.handle(\"RCPT TO: #{address}\")).to eq \"535 Mail server has been suspended\"\n        end\n\n        it \"returns an error if the route is set to Reject mail\" do\n          server = create(:server)\n          route = create(:route, server: server, mode: \"Reject\")\n          address = \"#{route.token}@#{Postal::Config.dns.route_domain}\"\n          expect(client.handle(\"RCPT TO: #{address}\")).to eq \"550 Route does not accept incoming messages\"\n        end\n\n        it \"adds a recipient if all OK\" do\n          server = create(:server)\n          route = create(:route, server: server)\n          address = \"#{route.token}+tag1@#{Postal::Config.dns.route_domain}\"\n          expect(client.handle(\"RCPT TO: #{address}\")).to eq \"250 OK\"\n          expect(client.recipients).to eq [[:route, \"#{route.name}+tag1@#{route.domain.name}\", server, { route: route }]]\n          expect(client.state).to eq :rcpt_to_received\n        end\n      end\n\n      context \"when authenticated and the RCPT TO address is provided\" do\n        it \"returns an error if the server is suspended\" do\n          server = create(:server, :suspended)\n          credential = create(:credential, server: server, type: \"SMTP\")\n          expect(client.handle(\"AUTH PLAIN #{credential.to_smtp_plain}\")).to match(/235 Granted for /)\n          expect(client.handle(\"RCPT TO: outgoing@example.com\")).to eq \"535 Mail server has been suspended\"\n        end\n\n        it \"adds a recipient if all OK\" do\n          server = create(:server)\n          credential = create(:credential, server: server, type: \"SMTP\")\n          expect(client.handle(\"AUTH PLAIN #{credential.to_smtp_plain}\")).to match(/235 Granted for /)\n          expect(client.handle(\"RCPT TO: outgoing@example.com\")).to eq \"250 OK\"\n          expect(client.recipients).to eq [[:credential, \"outgoing@example.com\", server]]\n          expect(client.state).to eq :rcpt_to_received\n        end\n      end\n\n      context \"when not authenticated and the RCPT TO address is a route\" do\n        it \"returns an error if the server is suspended\" do\n          server = create(:server, :suspended)\n          route = create(:route, server: server)\n          address = \"#{route.name}@#{route.domain.name}\"\n          expect(client.handle(\"RCPT TO: #{address}\")).to eq \"535 Mail server has been suspended\"\n        end\n\n        it \"returns an error if the route is set to Reject mail\" do\n          server = create(:server)\n          route = create(:route, server: server, mode: \"Reject\")\n          address = \"#{route.name}@#{route.domain.name}\"\n          expect(client.handle(\"RCPT TO: #{address}\")).to eq \"550 Route does not accept incoming messages\"\n        end\n\n        it \"adds a recipient if all OK\" do\n          server = create(:server)\n          route = create(:route, server: server)\n          address = \"#{route.name}@#{route.domain.name}\"\n          expect(client.handle(\"RCPT TO: #{address}\")).to eq \"250 OK\"\n          expect(client.recipients).to eq [[:route, address, server, { route: route }]]\n          expect(client.state).to eq :rcpt_to_received\n        end\n      end\n\n      context \"when not authenticated and RCPT TO does not match a route\" do\n        it \"returns an error\" do\n          expect(client.handle(\"RCPT TO: nothing@nothing.com\")).to eq \"530 Authentication required\"\n        end\n\n        context \"when the connecting IP has an credential\" do\n          it \"adds a recipient\" do\n            server = create(:server)\n            create(:credential, server: server, type: \"SMTP-IP\", key: \"1.0.0.0/8\")\n            address = \"test@example.com\"\n            expect(client.handle(\"RCPT TO: #{address}\")).to eq \"250 OK\"\n            expect(client.recipients).to eq [[:credential, address, server]]\n            expect(client.state).to eq :rcpt_to_received\n          end\n        end\n      end\n    end\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/smtp_server/client_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule SMTPServer\n\n  describe Client do\n    let(:ip_address) { \"1.2.3.4\" }\n    subject(:client) { described_class.new(ip_address) }\n  end\n\nend\n"
  },
  {
    "path": "spec/lib/worker/jobs/process_queued_messages_job_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule Worker\n  module Jobs\n\n    RSpec.describe ProcessQueuedMessagesJob do\n      subject(:job) { described_class.new(logger: Postal.logger) }\n\n      before do\n        allow(MessageDequeuer).to receive(:process)\n      end\n\n      describe \"#call\" do\n        context \"when there are no queued messages\" do\n          it \"does nothing\" do\n            job.call\n            expect(MessageDequeuer).to_not have_received(:process)\n          end\n        end\n\n        context \"when there is an unlocked queued message for an IP address that is not ours\" do\n          it \"does nothing\" do\n            ip_address = create(:ip_address)\n            queued_message = create(:queued_message, ip_address: ip_address)\n            job.call\n            expect(MessageDequeuer).to_not have_received(:process)\n            expect(queued_message.reload.locked?).to be false\n          end\n        end\n\n        context \"when there is an unlocked queued message without an IP address without a retry time\" do\n          it \"locks the message and calls the service\" do\n            queued_message = create(:queued_message, ip_address: nil, retry_after: nil)\n            job.call\n            expect(MessageDequeuer).to have_received(:process).with(queued_message, logger: kind_of(Klogger::Logger))\n            expect(queued_message.reload.locked?).to be true\n            expect(queued_message.locked_by).to match(/\\A#{Postal.locker_name} [a-f0-9]{16}\\z/)\n            expect(queued_message.locked_at).to be_within(1.second).of(Time.current)\n          end\n        end\n\n        context \"when there is an unlocked queued message without an IP address without a retry time in the past\" do\n          it \"locks the message and calls the service\" do\n            queued_message = create(:queued_message, ip_address: nil, retry_after: 10.minutes.ago)\n            job.call\n            expect(MessageDequeuer).to have_received(:process).with(queued_message, logger: kind_of(Klogger::Logger))\n            expect(queued_message.reload.locked?).to be true\n            expect(queued_message.locked_by).to match(/\\A#{Postal.locker_name} [a-f0-9]{16}\\z/)\n            expect(queued_message.locked_at).to be_within(1.second).of(Time.current)\n          end\n        end\n\n        context \"when there is an unlocked queued message without an IP address without a retry time in the future\" do\n          it \"does nothing\" do\n            queued_message = create(:queued_message, ip_address: nil, retry_after: 10.minutes.from_now)\n            job.call\n            expect(MessageDequeuer).to_not have_received(:process)\n            expect(queued_message.reload.locked?).to be false\n          end\n        end\n\n        context \"when there is a locked queued message without an IP address without a retry time\" do\n          it \"does nothing\" do\n            queued_message = create(:queued_message, :locked, ip_address: nil, retry_after: nil)\n            job.call\n            expect(MessageDequeuer).to_not have_received(:process)\n            expect(queued_message.reload.locked?).to be true\n          end\n        end\n\n        context \"when there is a locked queued message without an IP address with a retry time in the past\" do\n          it \"does nothing\" do\n            queued_message = create(:queued_message, :locked, ip_address: nil, retry_after: 1.month.ago)\n            job.call\n            expect(MessageDequeuer).to_not have_received(:process)\n            expect(queued_message.reload.locked?).to be true\n          end\n        end\n\n        context \"when there is an unlocked queued message with an IP address that is ours without a retry time\" do\n          it \"locks the message and calls the service\" do\n            ip_address = create(:ip_address, ipv4: \"10.20.30.40\")\n            allow(Socket).to receive(:ip_address_list).and_return([Addrinfo.new([\"AF_INET\", 1, \"localhost.localdomain\", \"10.20.30.40\"])])\n            queued_message = create(:queued_message, ip_address: ip_address)\n            job.call\n            expect(MessageDequeuer).to have_received(:process).with(queued_message, logger: kind_of(Klogger::Logger))\n            expect(queued_message.reload.locked?).to be true\n            expect(queued_message.locked_by).to match(/\\A#{Postal.locker_name} [a-f0-9]{16}\\z/)\n            expect(queued_message.locked_at).to be_within(1.second).of(Time.current)\n          end\n        end\n\n        context \"when there is an unlocked queued message with an IP address that is ours without a retry time in the future\" do\n          it \"does nothing\" do\n            ip_address = create(:ip_address, ipv4: \"10.20.30.40\")\n            allow(Socket).to receive(:ip_address_list).and_return([Addrinfo.new([\"AF_INET\", 1, \"localhost.localdomain\", \"10.20.30.40\"])])\n            queued_message = create(:queued_message, ip_address: ip_address, retry_after: 1.month.from_now)\n            job.call\n            expect(MessageDequeuer).to_not have_received(:process)\n            expect(queued_message.reload.locked?).to be false\n          end\n        end\n      end\n    end\n\n  end\nend\n"
  },
  {
    "path": "spec/lib/worker/jobs/process_webhook_requests_job_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nmodule Worker\n  module Jobs\n\n    RSpec.describe ProcessWebhookRequestsJob do\n      subject(:job) { described_class.new(logger: Postal.logger) }\n\n      let(:mocked_service) { double(\"Service\") }\n\n      before do\n        allow(WebhookDeliveryService).to receive(:new).and_return(mocked_service)\n        allow(mocked_service).to receive(:call).with(no_args)\n      end\n\n      context \"when there are no requests to process\" do\n        it \"does nothing\" do\n          job.call\n          expect(job.work_completed?).to be false\n        end\n      end\n\n      context \"when there is a unlocked request with no retry time\" do\n        it \"delivers the request\" do\n          request = create(:webhook_request)\n          job.call\n          expect(WebhookDeliveryService).to have_received(:new).with(webhook_request: request)\n          expect(job.work_completed?).to be true\n        end\n      end\n\n      context \"when there is an unlocked request with a retry time in the past\" do\n        it \"delivers the request\" do\n          request = create(:webhook_request, retry_after: 1.minute.ago)\n          job.call\n          expect(WebhookDeliveryService).to have_received(:new).with(webhook_request: request)\n          expect(job.work_completed?).to be true\n        end\n      end\n\n      context \"when there is an unlocked request with a retry time in the future\" do\n        it \"does nothing\" do\n          create(:webhook_request, retry_after: 1.minute.from_now)\n          job.call\n          expect(job.work_completed?).to be false\n        end\n      end\n\n      context \"when there is a locked requested without a retry time\" do\n        it \"does nothing\" do\n          create(:webhook_request, :locked)\n          job.call\n          expect(job.work_completed?).to be false\n        end\n      end\n    end\n\n  end\nend\n"
  },
  {
    "path": "spec/models/domain_spec.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: domains\n#\n#  id                     :integer          not null, primary key\n#  dkim_error             :string(255)\n#  dkim_identifier_string :string(255)\n#  dkim_private_key       :text(65535)\n#  dkim_status            :string(255)\n#  dns_checked_at         :datetime\n#  incoming               :boolean          default(TRUE)\n#  mx_error               :string(255)\n#  mx_status              :string(255)\n#  name                   :string(255)\n#  outgoing               :boolean          default(TRUE)\n#  owner_type             :string(255)\n#  return_path_error      :string(255)\n#  return_path_status     :string(255)\n#  spf_error              :string(255)\n#  spf_status             :string(255)\n#  use_for_any            :boolean\n#  uuid                   :string(255)\n#  verification_method    :string(255)\n#  verification_token     :string(255)\n#  verified_at            :datetime\n#  created_at             :datetime\n#  updated_at             :datetime\n#  owner_id               :integer\n#  server_id              :integer\n#\n# Indexes\n#\n#  index_domains_on_server_id  (server_id)\n#  index_domains_on_uuid       (uuid)\n#\nrequire \"rails_helper\"\n\ndescribe Domain do\n  subject(:domain) { build(:domain) }\n\n  describe \"relationships\" do\n    it { is_expected.to belong_to(:server).optional }\n    it { is_expected.to belong_to(:owner).optional }\n    it { is_expected.to have_many(:routes) }\n    it { is_expected.to have_many(:track_domains) }\n  end\n\n  describe \"validations\" do\n    it { is_expected.to validate_presence_of(:name) }\n    it { is_expected.to validate_uniqueness_of(:name).scoped_to([:owner_type, :owner_id]).case_insensitive.with_message(\"is already added\") }\n    it { is_expected.to allow_value(\"example.com\").for(:name) }\n    it { is_expected.to allow_value(\"example.co.uk\").for(:name) }\n    it { is_expected.to_not allow_value(\"EXAMPLE.COM\").for(:name) }\n    it { is_expected.to_not allow_value(\"example.com \").for(:name) }\n    it { is_expected.to_not allow_value(\"example com\").for(:name) }\n    it { is_expected.to validate_inclusion_of(:verification_method).in_array(Domain::VERIFICATION_METHODS) }\n  end\n\n  describe \"creation\" do\n    it \"creates a new dkim identifier string\" do\n      expect { domain.save }.to change { domain.dkim_identifier_string }.from(nil).to(match(/\\A[a-zA-Z0-9]{6}\\z/))\n    end\n\n    it \"generates a new dkim key\" do\n      expect { domain.save }.to change { domain.dkim_private_key }.from(nil).to(match(/\\A-+BEGIN RSA PRIVATE KEY-+/))\n    end\n\n    it \"generates a UUID\" do\n      expect { domain.save }.to change { domain.uuid }.from(nil).to(/[a-f0-9-]{36}/)\n    end\n  end\n\n  describe \".verified\" do\n    it \"returns verified domains only\" do\n      verified_domain = create(:domain)\n      create(:domain, :unverified)\n      expect(described_class.verified).to eq [verified_domain]\n    end\n  end\n\n  context \"when verification method changes\" do\n    context \"to DNS\" do\n      let(:domain) { create(:domain, :unverified, verification_method: \"Email\") }\n\n      it \"generates a DNS suitable verification token\" do\n        domain.verification_method = \"DNS\"\n        expect { domain.save }.to change { domain.verification_token }.from(match(/\\A\\d{6}\\z/)).to(match(/\\A[A-Za-z0-9+]{32}\\z/))\n      end\n    end\n\n    context \"to Email\" do\n      let(:domain) { create(:domain, :unverified, verification_method: \"DNS\") }\n\n      it \"generates an email suitable verification token\" do\n        domain.verification_method = \"Email\"\n        expect { domain.save }.to change { domain.verification_token }.from(match(/\\A[A-Za-z0-9+]{32}\\z/)).to(match(/\\A\\d{6}\\z/))\n      end\n    end\n  end\n\n  describe \"#verified?\" do\n    context \"when the domain is verified\" do\n      it \"returns true\" do\n        expect(domain.verified?).to be true\n      end\n    end\n\n    context \"when the domain is not verified\" do\n      let(:domain) { build(:domain, :unverified) }\n\n      it \"returns false\" do\n        expect(domain.verified?).to be false\n      end\n    end\n  end\n\n  describe \"#mark_as_verified\" do\n    context \"when already verified\" do\n      it \"returns false\" do\n        expect(domain.mark_as_verified).to be false\n      end\n    end\n\n    context \"when unverified\" do\n      let(:domain) { create(:domain, :unverified) }\n\n      it \"sets the verification time\" do\n        expect { domain.mark_as_verified }.to change { domain.verified_at }.from(nil).to(kind_of(Time))\n      end\n    end\n  end\n\n  describe \"#parent_domains\" do\n    context \"at level 1\" do\n      let(:domain) { build(:domain, name: \"example.com\") }\n\n      it \"returns the current domain only\" do\n        expect(domain.parent_domains).to eq [\"example.com\"]\n      end\n    end\n\n    context \"at level 2\" do\n      let(:domain) { build(:domain, name: \"test.example.com\") }\n\n      it \"returns the current domain plus its parent\" do\n        expect(domain.parent_domains).to eq [\"test.example.com\", \"example.com\"]\n      end\n    end\n\n    context \"at level 3 (and higher)\" do\n      let(:domain) { build(:domain, name: \"sub.test.example.com\") }\n\n      it \"returns the current domain plus its parents\" do\n        expect(domain.parent_domains).to eq [\"sub.test.example.com\", \"test.example.com\", \"example.com\"]\n      end\n    end\n  end\n\n  describe \"#generate_dkim_key\" do\n    it \"generates a new dkim key\" do\n      expect { domain.generate_dkim_key }.to change { domain.dkim_private_key }.from(nil).to(match(/\\A-+BEGIN RSA PRIVATE KEY-+/))\n    end\n  end\n\n  describe \"#dkim_key\" do\n    context \"when the domain has a DKIM key\" do\n      let(:domain) { create(:domain) }\n\n      it \"returns the dkim key as a OpenSSL::PKey::RSA\" do\n        expect(domain.dkim_key).to be_a OpenSSL::PKey::RSA\n        expect(domain.dkim_key.to_s).to eq domain.dkim_private_key\n      end\n    end\n\n    context \"when the domain has no DKIM key\" do\n      let(:domain) { build(:domain) }\n\n      it \"returns nil\" do\n        expect(domain.dkim_key).to be_nil\n      end\n    end\n  end\n\n  describe \"#to_param\" do\n    context \"when the domain has not been saved\" do\n      it \"returns nil\" do\n        expect(domain.to_param).to be_nil\n      end\n    end\n    context \"when the domain has been saved\" do\n      before do\n        domain.save\n      end\n\n      it \"returns the UUID\" do\n        expect(domain.to_param).to eq domain.uuid\n      end\n    end\n  end\n\n  describe \"#verification_email_addresses\" do\n    let(:domain) { build(:domain, name: \"example.com\") }\n\n    it \"returns the verification email addresses\" do\n      expect(domain.verification_email_addresses).to eq [\n        \"webmaster@example.com\",\n        \"postmaster@example.com\",\n        \"admin@example.com\",\n        \"administrator@example.com\",\n        \"hostmaster@example.com\",\n      ]\n    end\n  end\n\n  describe \"#spf_record\" do\n    it \"returns the SPF record\" do\n      expect(domain.spf_record).to eq \"v=spf1 a mx include:#{Postal::Config.dns.spf_include} ~all\"\n    end\n  end\n\n  describe \"#dkim_record\" do\n    context \"when the domain has no DKIM key\" do\n      it \"returns nil\" do\n        expect(domain.dkim_record).to be_nil\n      end\n    end\n\n    context \"when the domain has a DKIM key\" do\n      before do\n        domain.save\n      end\n\n      it \"returns the DKIM record\" do\n        expect(domain.dkim_record).to match(/\\Av=DKIM1; t=s; h=sha256; p=.*;\\z/)\n      end\n    end\n  end\n\n  describe \"#dkim_identifier\" do\n    context \"when the domain has no dkim identifier string\" do\n      it \"returns nil\" do\n        expect(domain.dkim_identifier).to be_nil\n      end\n    end\n\n    context \"when the domain has a dkim identifier string\" do\n      before do\n        domain.save\n      end\n\n      it \"returns the DKIM identifier\" do\n        expect(domain.dkim_identifier).to eq \"#{Postal::Config.dns.dkim_identifier}-#{domain.dkim_identifier_string}\"\n      end\n    end\n  end\n\n  describe \"#dkim_record_name\" do\n    context \"when the domain has no dkim identifier string\" do\n      it \"returns nil\" do\n        expect(domain.dkim_record_name).to be_nil\n      end\n    end\n\n    context \"when the domain has a dkim identifier string\" do\n      before do\n        domain.save\n      end\n\n      it \"returns the DKIM identifier\" do\n        expect(domain.dkim_record_name).to eq \"#{Postal::Config.dns.dkim_identifier}-#{domain.dkim_identifier_string}._domainkey\"\n      end\n    end\n  end\n\n  describe \"#return_path_domain\" do\n    it \"returns the return path domain\" do\n      expect(domain.return_path_domain).to eq \"#{Postal::Config.dns.custom_return_path_prefix}.#{domain.name}\"\n    end\n  end\n\n  describe \"#dns_verification_string\" do\n    let(:domain) { create(:domain, verification_method: \"DNS\") }\n\n    it \"returns the DNS verification string\" do\n      expect(domain.dns_verification_string).to eq \"#{Postal::Config.dns.domain_verify_prefix} #{domain.verification_token}\"\n    end\n  end\n\n  describe \"#resolver\" do\n    context \"when the local nameservers should be used\" do\n      before do\n        allow(Postal::Config.postal).to receive(:use_local_ns_for_domain_verification?).and_return(true)\n      end\n\n      it \"uses the local DNS\" do\n        expect(domain.resolver).to eq DNSResolver.local\n      end\n    end\n\n    context \"when local nameservers should not be used\" do\n      it \"uses the a resolver for this domain\" do\n        allow(DNSResolver).to receive(:for_domain).with(domain.name).and_return(DNSResolver.new([\"1.2.3.4\"]))\n        expect(domain.resolver).to be_a DNSResolver\n        expect(domain.resolver.nameservers).to eq [\"1.2.3.4\"]\n      end\n    end\n  end\n\n  describe \"#verify_with_dns\" do\n    context \"when the verification method is not DNS\" do\n      let(:domain) { build(:domain, verification_method: \"Email\") }\n\n      it \"returns false\" do\n        expect(domain.verify_with_dns).to be false\n      end\n    end\n\n    context \"when a TXT record is found that matches\" do\n      let(:domain) { create(:domain, :unverified) }\n\n      before do\n        allow(domain.resolver).to receive(:txt).with(domain.name).and_return([domain.dns_verification_string])\n      end\n\n      it \"returns true\" do\n        expect(domain.verify_with_dns).to be true\n      end\n\n      it \"sets the verification time\" do\n        expect { domain.verify_with_dns }.to change { domain.verified_at }.from(nil).to(kind_of(Time))\n      end\n    end\n\n    context \"when no TXT record is found\" do\n      let(:domain) { create(:domain, :unverified) }\n\n      before do\n        allow(domain.resolver).to receive(:txt).with(domain.name).and_return([\"something\", \"something else\"])\n      end\n\n      it \"returns false\" do\n        expect(domain.verify_with_dns).to be false\n      end\n\n      it \"does not set the verification time\" do\n        expect { domain.verify_with_dns }.to_not change { domain.verified_at } # rubocop:disable Lint/AmbiguousBlockAssociation\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/models/organization_spec.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: organizations\n#\n#  id                :integer          not null, primary key\n#  deleted_at        :datetime\n#  name              :string(255)\n#  permalink         :string(255)\n#  suspended_at      :datetime\n#  suspension_reason :string(255)\n#  time_zone         :string(255)\n#  uuid              :string(255)\n#  created_at        :datetime\n#  updated_at        :datetime\n#  ip_pool_id        :integer\n#  owner_id          :integer\n#\n# Indexes\n#\n#  index_organizations_on_permalink  (permalink)\n#  index_organizations_on_uuid       (uuid)\n#\nrequire \"rails_helper\"\n\ndescribe Organization do\n  context \"model\" do\n    subject(:organization) { create(:organization) }\n\n    it \"should have a UUID\" do\n      expect(organization.uuid).to be_a String\n      expect(organization.uuid.length).to eq 36\n    end\n  end\nend\n"
  },
  {
    "path": "spec/models/outgoing_message_prototype_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\ndescribe OutgoingMessagePrototype do\n  let(:server) { create(:server) }\n  it \"should create a new message\" do\n    domain = create(:domain, owner: server)\n    prototype = OutgoingMessagePrototype.new(server, \"127.0.0.1\", \"TestSuite\", {\n      from: \"test@#{domain.name}\",\n      to: \"test@example.com\",\n      subject: \"Test Message\",\n      plain_body: \"A plain body!\"\n    })\n\n    expect(prototype.valid?).to be true\n    message = prototype.create_message(\"test@example.com\")\n    expect(message).to be_a Hash\n    expect(message[:id]).to be_a Integer\n    expect(message[:token]).to be_a String\n  end\nend\n"
  },
  {
    "path": "spec/models/queued_message_spec.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: queued_messages\n#\n#  id            :integer          not null, primary key\n#  attempts      :integer          default(0)\n#  batch_key     :string(255)\n#  domain        :string(255)\n#  locked_at     :datetime\n#  locked_by     :string(255)\n#  manual        :boolean          default(FALSE)\n#  retry_after   :datetime\n#  created_at    :datetime\n#  updated_at    :datetime\n#  ip_address_id :integer\n#  message_id    :integer\n#  route_id      :integer\n#  server_id     :integer\n#\n# Indexes\n#\n#  index_queued_messages_on_domain      (domain)\n#  index_queued_messages_on_message_id  (message_id)\n#  index_queued_messages_on_server_id   (server_id)\n#\nrequire \"rails_helper\"\n\nRSpec.describe QueuedMessage do\n  subject(:queued_message) { build(:queued_message) }\n\n  describe \"relationships\" do\n    it { is_expected.to belong_to(:server) }\n    it { is_expected.to belong_to(:ip_address).optional }\n  end\n\n  describe \".ready_with_delayed_retry\" do\n    it \"returns messages where retry after is null\" do\n      message = create(:queued_message, retry_after: nil)\n      expect(described_class.ready_with_delayed_retry).to eq [message]\n    end\n\n    it \"returns messages where retry after is less than 30 seconds from now\" do\n      Timecop.freeze do\n        message1 = create(:queued_message, retry_after: 45.seconds.ago)\n        message2 = create(:queued_message, retry_after: 5.minutes.ago)\n        create(:queued_message, retry_after: Time.now)\n        create(:queued_message, retry_after: 1.minute.from_now)\n        expect(described_class.ready_with_delayed_retry.order(:id)).to eq [message1, message2]\n      end\n    end\n  end\n\n  describe \".with_stale_lock\" do\n    it \"returns messages where lock time is less than the configured number of stale days\" do\n      allow(Postal::Config.postal).to receive(:queued_message_lock_stale_days).and_return(2)\n      message1 = create(:queued_message, locked_at: 3.days.ago, locked_by: \"test\")\n      message2 = create(:queued_message, locked_at: 2.days.ago, locked_by: \"test\")\n      create(:queued_message, locked_at: 1.days.ago, locked_by: \"test\")\n      create(:queued_message)\n      expect(described_class.with_stale_lock.order(:id)).to eq [message1, message2]\n    end\n  end\n\n  describe \"#retry_now\" do\n    it \"removes the retry time\" do\n      message = create(:queued_message, retry_after: 2.minutes.from_now)\n      expect { message.retry_now }.to change { message.reload.retry_after }.from(kind_of(Time)).to(nil)\n    end\n\n    it \"raises an error if invalid\" do\n      message = create(:queued_message, retry_after: 2.minutes.from_now)\n      message.update_columns(server_id: nil) # unlikely to actually happen\n      expect { message.retry_now }.to raise_error(ActiveRecord::RecordInvalid)\n    end\n  end\n\n  describe \"#send_bounce\" do\n    let(:server) { create(:server) }\n    let(:message) { MessageFactory.incoming(server) }\n\n    subject(:queued_message) { create(:queued_message, message: message) }\n\n    context \"when the message is eligiable for bounces\" do\n      it \"queues a bounce message for sending\" do\n        expect(BounceMessage).to receive(:new).with(server, kind_of(Postal::MessageDB::Message)).and_wrap_original do |original, *args|\n          bounce = original.call(*args)\n          expect(bounce).to receive(:queue)\n          bounce\n        end\n        queued_message.send_bounce\n      end\n    end\n\n    context \"when the message is not eligible for bounces\" do\n      it \"returns nil\" do\n        message.update(bounce: true)\n        expect(queued_message.send_bounce).to be nil\n      end\n\n      it \"does not queue a bounce message for sending\" do\n        message.update(bounce: true)\n        expect(BounceMessage).not_to receive(:new)\n        queued_message.send_bounce\n      end\n    end\n  end\n\n  describe \"#allocate_ip_address\" do\n    subject(:queued_message) { create(:queued_message) }\n\n    context \"when ip pools is disabled\" do\n      it \"returns nil\" do\n        expect(queued_message.allocate_ip_address).to be nil\n      end\n\n      it \"does not allocate an IP address\" do\n        expect { queued_message.allocate_ip_address }.not_to change(queued_message, :ip_address)\n      end\n    end\n\n    context \"when IP pools is enabled\" do\n      before do\n        allow(Postal::Config.postal).to receive(:use_ip_pools?).and_return(true)\n      end\n\n      context \"when there is no backend message\" do\n        it \"returns nil\" do\n          expect(queued_message.allocate_ip_address).to be nil\n        end\n\n        it \"does not allocate an IP address\" do\n          expect { queued_message.allocate_ip_address }.not_to change(queued_message, :ip_address)\n        end\n      end\n\n      context \"when no IP pool can be determined for the message\" do\n        let(:server) { create(:server) }\n        let(:message) { MessageFactory.outgoing(server) }\n\n        subject(:queued_message) { create(:queued_message, message: message) }\n\n        it \"returns nil\" do\n          expect(queued_message.allocate_ip_address).to be nil\n        end\n\n        it \"does not allocate an IP address\" do\n          expect { queued_message.allocate_ip_address }.not_to change(queued_message, :ip_address)\n        end\n      end\n\n      context \"when an IP pool can be determined for the message\" do\n        let(:ip_pool) { create(:ip_pool, :with_ip_address) }\n        let(:server) { create(:server, ip_pool: ip_pool) }\n        let(:message) { MessageFactory.outgoing(server) }\n\n        subject(:queued_message) { create(:queued_message, message: message) }\n\n        it \"returns an IP address\" do\n          expect(queued_message.allocate_ip_address).to be_a IPAddress\n        end\n\n        it \"allocates an IP address to the queued message\" do\n          queued_message.update(ip_address: nil)\n          expect { queued_message.allocate_ip_address }.to change(queued_message, :ip_address).from(nil).to(ip_pool.ip_addresses.first)\n        end\n      end\n    end\n  end\n\n  describe \"#batchable_messages\" do\n    context \"when the message is not locked\" do\n      subject(:queued_message) { build(:queued_message) }\n\n      it \"raises an error\" do\n        expect { queued_message.batchable_messages }.to raise_error(Postal::Error, /must lock current message before locking any friends/i)\n      end\n    end\n\n    context \"when the message is locked\" do\n      let(:batch_key) { nil }\n      subject(:queued_message) { build(:queued_message, :locked, batch_key: batch_key) }\n\n      context \"when there is no batch key on the queued message\" do\n        it \"returns an empty array\" do\n          expect(queued_message.batch_key).to be nil\n          expect(queued_message.batchable_messages).to eq []\n        end\n      end\n\n      context \"when there is a batch key\" do\n        let(:batch_key) { \"1234\" }\n\n        it \"finds and locks messages with the same batch key and IP address up to the limit specified\" do\n          other_message1 = create(:queued_message, batch_key: batch_key, ip_address: nil)\n          other_message2 = create(:queued_message, batch_key: batch_key, ip_address: nil)\n          create(:queued_message, batch_key: batch_key, ip_address: nil)\n\n          messages = queued_message.batchable_messages(2)\n          expect(messages).to eq [other_message1, other_message2]\n          expect(messages).to all be_locked\n        end\n\n        it \"does not find messages with a different batch key\" do\n          create(:queued_message, batch_key: \"5678\", ip_address: nil)\n          expect(queued_message.batchable_messages).to eq []\n        end\n\n        it \"does not find messages that are not queued for sending yet\" do\n          create(:queued_message, batch_key: batch_key, ip_address: nil, retry_after: 1.minute.from_now)\n          expect(queued_message.batchable_messages).to eq []\n        end\n\n        it \"does not find messages that are for a different IP address\" do\n          create(:queued_message, batch_key: batch_key, ip_address: create(:ip_address))\n          expect(queued_message.batchable_messages).to eq []\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/models/server_spec.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: servers\n#\n#  id                                 :integer          not null, primary key\n#  allow_sender                       :boolean          default(FALSE)\n#  deleted_at                         :datetime\n#  domains_not_to_click_track         :text(65535)\n#  log_smtp_data                      :boolean          default(FALSE)\n#  message_retention_days             :integer\n#  mode                               :string(255)\n#  name                               :string(255)\n#  outbound_spam_threshold            :decimal(8, 2)\n#  permalink                          :string(255)\n#  postmaster_address                 :string(255)\n#  privacy_mode                       :boolean          default(FALSE)\n#  raw_message_retention_days         :integer\n#  raw_message_retention_size         :integer\n#  send_limit                         :integer\n#  send_limit_approaching_at          :datetime\n#  send_limit_approaching_notified_at :datetime\n#  send_limit_exceeded_at             :datetime\n#  send_limit_exceeded_notified_at    :datetime\n#  spam_failure_threshold             :decimal(8, 2)\n#  spam_threshold                     :decimal(8, 2)\n#  suspended_at                       :datetime\n#  suspension_reason                  :string(255)\n#  token                              :string(255)\n#  uuid                               :string(255)\n#  created_at                         :datetime\n#  updated_at                         :datetime\n#  ip_pool_id                         :integer\n#  organization_id                    :integer\n#\n# Indexes\n#\n#  index_servers_on_organization_id  (organization_id)\n#  index_servers_on_permalink        (permalink)\n#  index_servers_on_token            (token)\n#  index_servers_on_uuid             (uuid)\n#\nrequire \"rails_helper\"\n\ndescribe Server do\n  subject(:server) { build(:server) }\n\n  describe \"relationships\" do\n    it { is_expected.to belong_to(:organization) }\n    it { is_expected.to belong_to(:ip_pool).optional }\n    it { is_expected.to have_many(:domains) }\n    it { is_expected.to have_many(:credentials) }\n    it { is_expected.to have_many(:smtp_endpoints) }\n    it { is_expected.to have_many(:http_endpoints) }\n    it { is_expected.to have_many(:address_endpoints) }\n    it { is_expected.to have_many(:routes) }\n    it { is_expected.to have_many(:queued_messages) }\n    it { is_expected.to have_many(:webhooks) }\n    it { is_expected.to have_many(:webhook_requests) }\n    it { is_expected.to have_many(:track_domains) }\n    it { is_expected.to have_many(:ip_pool_rules) }\n  end\n\n  describe \"validations\" do\n    it { is_expected.to validate_presence_of(:name) }\n    it { is_expected.to validate_uniqueness_of(:name).scoped_to(:organization_id).case_insensitive }\n    it { is_expected.to validate_inclusion_of(:mode).in_array(Server::MODES) }\n    it { is_expected.to validate_uniqueness_of(:permalink).scoped_to(:organization_id).case_insensitive }\n    it { is_expected.to validate_exclusion_of(:permalink).in_array(Server::RESERVED_PERMALINKS) }\n    it { is_expected.to allow_value(\"hello\").for(:permalink) }\n    it { is_expected.to allow_value(\"hello-world\").for(:permalink) }\n    it { is_expected.to allow_value(\"hello1234\").for(:permalink) }\n    it { is_expected.not_to allow_value(\"LARGE\").for(:permalink) }\n    it { is_expected.not_to allow_value(\" lots of spaces \").for(:permalink) }\n    it { is_expected.not_to allow_value(\"hello+\").for(:permalink) }\n    it { is_expected.not_to allow_value(\"!!!\").for(:permalink) }\n    it { is_expected.not_to allow_value(\"[hello]\").for(:permalink) }\n\n    describe \"ip pool validation\" do\n      let(:org) { create(:organization) }\n      let(:ip_pool) { create(:ip_pool) }\n      let(:server) { build(:server, organization: org, ip_pool: ip_pool) }\n\n      context \"when the IP pool does not belong to the same organization\" do\n        it \"adds an error\" do\n          expect(server.save).to be false\n          expect(server.errors[:ip_pool_id]).to include(/must belong to the organization/)\n        end\n      end\n\n      context \"whent he IP pool does belong to the the same organization\" do\n        before do\n          org.ip_pools << ip_pool\n        end\n\n        it \"does not add an error\" do\n          expect(server.save).to be true\n        end\n      end\n    end\n  end\n\n  describe \"creation\" do\n    let(:server) { build(:server) }\n\n    it \"generates a uuid\" do\n      expect { server.save }.to change { server.uuid }.from(nil).to(/[a-f0-9-]{36}/)\n    end\n\n    it \"generates a token\" do\n      expect { server.save }.to change { server.token }.from(nil).to(/[a-z0-9]{6}/)\n    end\n\n    it \"provisions a database\" do\n      expect(server.message_db.provisioner).to receive(:provision).once\n      server.provision_database = true\n      server.save\n    end\n  end\n\n  describe \"deletion\" do\n    let(:server) { create(:server) }\n\n    it \"removes the database\" do\n      expect(server.message_db.provisioner).to receive(:drop).once\n      server.provision_database = true\n      server.destroy\n    end\n  end\n\n  describe \"#status\" do\n    context \"when the server is suspended\" do\n      let(:server) { build(:server, :suspended) }\n\n      it \"returns Suspended\" do\n        expect(server.status).to eq(\"Suspended\")\n      end\n    end\n\n    context \"when the server is not suspended\" do\n      it \"returns the mode\" do\n        expect(server.status).to eq \"Live\"\n      end\n    end\n  end\n\n  describe \"#full_permalink\" do\n    it \"returns the org and server permalinks concatenated\" do\n      expect(server.full_permalink).to eq \"#{server.organization.permalink}/#{server.permalink}\"\n    end\n  end\n\n  describe \"#suspended?\" do\n    context \"when the server is suspended\" do\n      let(:server) { build(:server, :suspended) }\n\n      it \"returns true\" do\n        expect(server).to be_suspended\n      end\n    end\n\n    context \"when the server is not suspended\" do\n      it \"returns false\" do\n        expect(server).not_to be_suspended\n      end\n    end\n  end\n\n  describe \"#actual_suspension_reason\" do\n    context \"when the server is not suspended\" do\n      it \"returns nil\" do\n        expect(server.actual_suspension_reason).to be_nil\n      end\n    end\n\n    context \"when the server is not suspended by the organization is\" do\n      let(:org) { build(:organization, :suspended, suspension_reason: \"org test\") }\n      let(:server) { build(:server, organization: org) }\n\n      it \"returns the organization suspension reason\" do\n        expect(server.actual_suspension_reason).to eq \"org test\"\n      end\n    end\n\n    context \"when the server is suspended\" do\n      let(:server) { build(:server, :suspended, suspension_reason: \"server test\") }\n\n      it \"returns the suspension reason\" do\n        expect(server.actual_suspension_reason).to eq \"server test\"\n      end\n    end\n  end\n\n  describe \"#to_param\" do\n    it \"returns the permalink\" do\n      expect(server.to_param).to eq server.permalink\n    end\n  end\n\n  describe \"#message_db\" do\n    it \"returns a message DB instance\" do\n      expect(server.message_db).to be_a Postal::MessageDB::Database\n      expect(server.message_db).to have_attributes(server_id: server.id, organization_id: server.organization.id)\n    end\n\n    it \"caches the value\" do\n      call1 = server.message_db\n      call2 = server.message_db\n      expect(call1.object_id).to eq(call2.object_id)\n    end\n  end\n\n  describe \"#message\" do\n    it \"delegates to the message db\" do\n      expect(server.message_db).to receive(:message).with(1)\n      server.message(1)\n    end\n  end\n\n  describe \"#message_rate\" do\n    it \"returns the live stats for the last hour per minute\" do\n      allow(server.message_db.live_stats).to receive(:total).and_return(600)\n      expect(server.message_rate).to eq 10\n      expect(server.message_db.live_stats).to have_received(:total).with(60, types: [:incoming, :outgoing])\n    end\n  end\n\n  describe \"#held_messages\" do\n    it \"returns the number of held messages\" do\n      expect(server.message_db).to receive(:messages).with(count: true, where: { held: true }).and_return(50)\n      expect(server.held_messages).to eq 50\n    end\n  end\n\n  describe \"#throughput_stats\" do\n    before do\n      allow(server.message_db.live_stats).to receive(:total).with(60, types: [:incoming]).and_return(50)\n      allow(server.message_db.live_stats).to receive(:total).with(60, types: [:outgoing]).and_return(100)\n    end\n\n    context \"when the server has a sent limit\" do\n      let(:server) { build(:server, send_limit: 500) }\n\n      it \"returns the stats with an outgoing usage percentage\" do\n        expect(server.throughput_stats).to eq({\n          incoming: 50,\n          outgoing: 100,\n          outgoing_usage: 20.0\n        })\n      end\n    end\n\n    context \"when the server does not have a sent limit\" do\n      it \"returns the stats with no outgoing usage percentage\" do\n        expect(server.throughput_stats).to eq({\n          incoming: 50,\n          outgoing: 100,\n          outgoing_usage: 0\n        })\n      end\n    end\n  end\n\n  describe \"#bounce_rate\" do\n    context \"when there are no outgoing emails\" do\n      it \"returns zero\" do\n        expect(server.bounce_rate).to eq 0\n      end\n    end\n\n    context \"when there are outgoing emails with some bounces\" do\n      it \"returns the rate\" do\n        allow(server.message_db.statistics).to receive(:get).with(:daily, [:outgoing, :bounces], kind_of(Time), 30)\n                                                            .and_return({\n                                                                          10.minutes.ago => { outgoing: 150, bounces: 50 },\n                                                                          5.minutes.ago => { outgoing: 350, bounces: 30 },\n                                                                          1.minutes.ago => { outgoing: 500, bounces: 20 }\n                                                                        })\n        expect(server.bounce_rate).to eq 10.0\n      end\n    end\n  end\n\n  describe \"#domain_stats\" do\n    it \"returns stats about the domains associated with the server\" do\n      create(:domain, owner: server) # verified, bad dns\n      create(:domain, :unverified, owner: server) # unverified\n      create(:domain, :dns_all_ok, owner: server) # verified good dns\n\n      expect(server.domain_stats).to eq [3, 1, 1]\n    end\n  end\n\n  describe \"#webhook_hash\" do\n    it \"returns a hash to represent the server\" do\n      expect(server.webhook_hash).to eq({\n        uuid: server.uuid,\n        name: server.name,\n        permalink: server.permalink,\n        organization: server.organization.permalink\n      })\n    end\n  end\n\n  describe \"#send_volume\" do\n    it \"returns the number of outgoing messages sent in the last hour\" do\n      allow(server.message_db.live_stats).to receive(:total).with(60, types: [:outgoing]).and_return(50)\n      expect(server.send_volume).to eq 50\n    end\n  end\n\n  describe \"#send_limit_approaching?\" do\n    context \"when the server has no send limit\" do\n      it \"returns false\" do\n        expect(server.send_limit_approaching?).to be false\n      end\n    end\n\n    context \"when the server has a send limit\" do\n      let(:server) { build(:server, send_limit: 1000) }\n\n      context \"when the server's send volume is less 90% of the limit\" do\n        it \"return false\" do\n          allow(server).to receive(:send_volume).and_return(800)\n          expect(server.send_limit_approaching?).to be false\n        end\n      end\n\n      context \"when the server's send volume is more than 90% of the limit\" do\n        it \"returns true\" do\n          allow(server).to receive(:send_volume).and_return(901)\n          expect(server.send_limit_approaching?).to be true\n        end\n      end\n    end\n  end\n\n  describe \"#send_limit_warning\" do\n    let(:server) { create(:server, send_limit: 1000) }\n\n    before do\n      allow(server).to receive(:send_volume).and_return(500)\n    end\n\n    context \"when given the :approaching argument\" do\n      it \"sends an email to the org notification addresses\" do\n        server.organization.users << create(:user)\n\n        server.send_limit_warning(:approaching)\n        delivery = ActionMailer::Base.deliveries.last\n        expect(delivery).to have_attributes(subject: /mail server is approaching its send limit/i)\n      end\n\n      it \"sets the notification time\" do\n        expect { server.send_limit_warning(:approaching) }.to change { server.send_limit_approaching_notified_at }\n          .from(nil).to(kind_of(Time))\n      end\n\n      it \"triggers a webhook\" do\n        expect(WebhookRequest).to receive(:trigger).with(server, \"SendLimitApproaching\", server: server.webhook_hash, volume: 500, limit: 1000)\n        server.send_limit_warning(:approaching)\n      end\n    end\n\n    context \"when given the :exceeded argument\" do\n      it \"sends an email to the org notification addresses\" do\n        server.organization.users << create(:user)\n\n        server.send_limit_warning(:exceeded)\n        delivery = ActionMailer::Base.deliveries.last\n        expect(delivery).to have_attributes(subject: /mail server has exceeded its send limit/i)\n      end\n\n      it \"sets the notification time\" do\n        expect { server.send_limit_warning(:exceeded) }.to change { server.send_limit_exceeded_notified_at }\n          .from(nil).to(kind_of(Time))\n      end\n\n      it \"triggers a webhook\" do\n        expect(WebhookRequest).to receive(:trigger).with(server, \"SendLimitExceeded\", server: server.webhook_hash, volume: 500, limit: 1000)\n        server.send_limit_warning(:exceeded)\n      end\n    end\n  end\n\n  describe \"#queue_size\" do\n    it \"returns the number of queued messages that are ready\" do\n      create(:queued_message, server: server, retry_after: nil)\n      create(:queued_message, server: server, retry_after: 1.minute.ago)\n      expect(server.queue_size).to eq 2\n    end\n  end\n\n  describe \"#authenticated_domain_for_address\" do\n    context \"when the address given is blank\" do\n      it \"returns nil\" do\n        expect(server.authenticated_domain_for_address(\"\")).to be nil\n        expect(server.authenticated_domain_for_address(nil)).to be nil\n      end\n    end\n\n    context \"when the address given does not have a username & domain component\" do\n      it \"returns nil\" do\n        expect(server.authenticated_domain_for_address(\"blah\")).to be nil\n      end\n    end\n\n    context \"when there is a verified org-level domain matching the address provided\" do\n      it \"returns that domain\" do\n        server = create(:server)\n        domain = create(:domain, owner: server.organization, name: \"mangos.io\")\n        expect(server.authenticated_domain_for_address(\"hello@mangos.io\")).to eq domain\n      end\n    end\n\n    context \"when there is a verified server-level domain matching the address provided\" do\n      it \"returns that domain\" do\n        domain = create(:domain, owner: server, name: \"oranges.io\")\n        expect(server.authenticated_domain_for_address(\"hello@oranges.io\")).to eq domain\n      end\n    end\n\n    context \"when there is a verified server-level domain matching the address and a use_for_any\" do\n      it \"returns the matching domain\" do\n        domain = create(:domain, owner: server, name: \"oranges.io\")\n        create(:domain, owner: server, name: \"pears.com\", use_for_any: true)\n        expect(server.authenticated_domain_for_address(\"hello@oranges.io\")).to eq domain\n      end\n    end\n\n    context \"when there is a verified server-level and org-level domain with the same name\" do\n      it \"returns the server-level domain\" do\n        domain = create(:domain, owner: server, name: \"lemons.com\")\n        create(:domain, owner: server.organization, name: \"lemons.com\")\n        expect(server.authenticated_domain_for_address(\"hello@lemons.com\")).to eq domain\n      end\n    end\n\n    context \"when there is a verified server-level domain with the 'use_for_any' boolean set with a different name\" do\n      it \"returns that domain\" do\n        create(:domain, owner: server, name: \"pears.com\")\n        domain = create(:domain, owner: server, name: \"apples.io\", use_for_any: true)\n        expect(server.authenticated_domain_for_address(\"hello@bananas.com\")).to eq domain\n      end\n    end\n\n    context \"when there is no suitable domain\" do\n      it \"returns nil\" do\n        server = create(:server)\n        create(:domain, owner: server, name: \"pears.com\")\n        create(:domain, owner: server.organization, name: \"pineapples.com\")\n        expect(server.authenticated_domain_for_address(\"hello@bananas.com\")).to be nil\n      end\n    end\n  end\n\n  describe \"#find_authenticated_domain_from_headers\" do\n    context \"when none of the from addresses have a valid domain\" do\n      it \"returns nil\" do\n        expect(server.find_authenticated_domain_from_headers(\"from\" => \"test@lemons.com\")).to be nil\n      end\n    end\n\n    context \"when the from addresses has a valid domain\" do\n      it \"returns the domain\" do\n        domain = create(:domain, owner: server)\n        expect(server.find_authenticated_domain_from_headers(\"from\" => \"hello@#{domain.name}\")).to eq domain\n      end\n    end\n\n    context \"when there are multiple from addresses\" do\n      context \"when none of them match a domain\" do\n        it \"returns nil\" do\n          expect(server.find_authenticated_domain_from_headers(\"from\" => [\"hello@lemons.com\", \"hello@apples.com\"])).to be nil\n        end\n      end\n\n      context \"when some but not all match\" do\n        it \"returns nil\" do\n          domain = create(:domain, owner: server)\n          expect(server.find_authenticated_domain_from_headers(\"from\" => [\"hello@#{domain.name}\", \"hello@lemons.com\"])).to be nil\n        end\n      end\n\n      context \"when all match\" do\n        it \"returns the first domain that matched\" do\n          domain1 = create(:domain, owner: server)\n          domain2 = create(:domain, owner: server)\n          expect(server.find_authenticated_domain_from_headers(\"from\" => [\"hello@#{domain1.name}\", \"hello@#{domain2.name}\"])).to eq domain1\n        end\n      end\n    end\n\n    context \"when the server is not allowed to use the sender header\" do\n      context \"when the sender header has a valid address\" do\n        it \"does not return the domain\" do\n          domain = create(:domain, owner: server)\n          result = server.find_authenticated_domain_from_headers(\n            \"from\" => \"hello@lemons.com\",\n            \"sender\" => \"hello@#{domain.name}\"\n          )\n          expect(result).to be nil\n        end\n      end\n    end\n\n    context \"when the server is allowed to use the sender header\" do\n      let(:server) { build(:server, allow_sender: true) }\n\n      context \"when none of the from addresses match but sender domains do\" do\n        it \"returns the domain that does match\" do\n          domain = create(:domain, owner: server)\n          result = server.find_authenticated_domain_from_headers(\n            \"from\" => \"hello@lemons.com\",\n            \"sender\" => \"hello@#{domain.name}\"\n          )\n          expect(result).to eq domain\n        end\n      end\n    end\n  end\n\n  describe \"#suspend\" do\n    let(:server) { create(:server) }\n\n    it \"sets the suspension time\" do\n      expect { server.suspend(\"some reason\") }.to change { server.reload.suspended_at }.from(nil).to(kind_of(Time))\n    end\n\n    it \"sets the suspension reason\" do\n      expect { server.suspend(\"some reason\") }.to change { server.reload.suspension_reason }.from(nil).to(\"some reason\")\n    end\n\n    context \"when there are no notification addresses\" do\n      it \"does not send an email\" do\n        server.suspend(\"some reason\")\n        expect(ActionMailer::Base.deliveries).to be_empty\n      end\n    end\n\n    context \"when there are notification addresses\" do\n      before do\n        server.organization.users << create(:user)\n      end\n\n      it \"sends an email\" do\n        server.suspend(\"some reason\")\n        delivery = ActionMailer::Base.deliveries.last\n        expect(delivery).to have_attributes(subject: /server has been suspended/i)\n      end\n    end\n  end\n\n  describe \"#unsuspend\" do\n    let(:server) { create(:server, :suspended) }\n\n    it \"removes the suspension time\" do\n      expect { server.unsuspend }.to change { server.reload.suspended_at }.to(nil)\n    end\n\n    it \"removes the suspension reason\" do\n      expect { server.unsuspend }.to change { server.reload.suspension_reason }.to(nil)\n    end\n  end\n\n  describe \"#ip_pool_for_message\" do\n    context \"when the message is not outgoing\" do\n      let(:message) { MessageFactory.incoming(server) }\n\n      it \"returns nil\" do\n        expect(server.ip_pool_for_message(message)).to be nil\n      end\n    end\n\n    context \"when a server rule matches the message\" do\n      let(:domain) { create(:domain, owner: server) }\n      let(:ip_pool) { create(:ip_pool, organizations: [server.organization]) }\n      let(:message) do\n        MessageFactory.outgoing(server, domain: domain) do |msg|\n          msg.rcpt_to = \"hello@google.com\"\n        end\n      end\n\n      before do\n        create(:ip_pool_rule, ip_pool: ip_pool, owner: server, from_text: nil, to_text: \"google.com\")\n      end\n\n      it \"returns the pool\" do\n        expect(server.ip_pool_for_message(message)).to eq ip_pool\n      end\n    end\n\n    context \"when an org rule matches the message\" do\n      let(:domain) { create(:domain, owner: server) }\n      let(:ip_pool) { create(:ip_pool, organizations: [server.organization]) }\n      let(:message) do\n        MessageFactory.outgoing(server, domain: domain) do |msg|\n          msg.rcpt_to = \"hello@google.com\"\n        end\n      end\n\n      before do\n        create(:ip_pool_rule, ip_pool: ip_pool, owner: server.organization, from_text: nil, to_text: \"google.com\")\n      end\n\n      it \"returns the pool\" do\n        expect(server.ip_pool_for_message(message)).to eq ip_pool\n      end\n    end\n\n    context \"when the server has no default pool and no rules match the message\" do\n      let(:domain) { create(:domain, owner: server) }\n      let(:message) { MessageFactory.outgoing(server, domain: domain) }\n\n      it \"returns nil\" do\n        expect(server.ip_pool_for_message(message)).to be nil\n      end\n    end\n\n    context \"when the server has a default pool and no rules match the message\" do\n      let(:organization) { create(:organization) }\n      let(:ip_pool) { create(:ip_pool, organizations: [organization]) }\n      let(:server) { create(:server, organization: organization, ip_pool: ip_pool) }\n      let(:domain) { create(:domain, owner: server) }\n      let(:message) { MessageFactory.outgoing(server, domain: domain) }\n\n      it \"returns the server's default pool\" do\n        expect(server.ip_pool_for_message(message)).to eq ip_pool\n      end\n    end\n  end\n\n  describe \".[]\" do\n    context \"when provided with an integer\" do\n      it \"returns the server with that ID\" do\n        server = create(:server)\n        expect(described_class[server.id]).to eq server\n      end\n\n      it \"returns nil if no server exists with the ID\" do\n        expect(described_class[1234]).to be nil\n      end\n    end\n\n    context \"when provided with a string\" do\n      it \"returns the server that matches the given permalinks\" do\n        server = create(:server)\n        expect(described_class[\"#{server.organization.permalink}/#{server.permalink}\"]).to eq server\n      end\n\n      it \"returns nil if no server exists\" do\n        expect(described_class[\"hello/world\"]).to be nil\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/models/user/authentication_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nRSpec.describe User do\n  describe \".authenticate\" do\n    it \"does not authenticate users with invalid emails\" do\n      expect { User.authenticate(\"nothing@nothing.com\", \"hello\") }.to raise_error(Postal::Errors::AuthenticationError) do |e|\n        expect(e.error).to eq \"InvalidEmailAddress\"\n      end\n    end\n\n    it \"does not authenticate users with invalid passwords\" do\n      user = create(:user)\n      expect { User.authenticate(user.email_address, \"hello\") }.to raise_error(Postal::Errors::AuthenticationError) do |e|\n        expect(e.error).to eq \"InvalidPassword\"\n      end\n    end\n\n    it \"authenticates valid users\" do\n      user = create(:user)\n      auth_user = nil\n      expect { auth_user = User.authenticate(user.email_address, \"passw0rd\") }.to_not raise_error\n      expect(auth_user).to eq user\n    end\n  end\nend\n"
  },
  {
    "path": "spec/models/user/oidc_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nRSpec.describe User do\n  let(:user) { build(:user) }\n\n  describe \"#oidc?\" do\n    it \"returns true if the user has an OIDC UID\" do\n      user.oidc_uid = \"123\"\n      expect(user.oidc?).to be true\n    end\n\n    it \"returns false if the user does not have an OIDC UID\" do\n      user.oidc_uid = nil\n      expect(user.oidc?).to be false\n    end\n  end\n\n  describe \".find_from_oidc\" do\n    let(:issuer) { \"https://identity.example.com\" }\n\n    before do\n      allow(Postal::Config.oidc).to receive(:enabled?).and_return(true)\n      allow(Postal::Config.oidc).to receive(:issuer).and_return(issuer)\n      allow(Postal::Config.oidc).to receive(:email_address_field).and_return(\"email\")\n    end\n\n    let(:uid) { \"abcdef\" }\n    let(:oidc_name) { \"John Smith\" }\n    let(:oidc_email) { \"test@example.com\" }\n\n    let(:auth) { { \"sub\" => uid, \"email\" => oidc_email, \"name\" => oidc_name } }\n    let(:logger) { TestLogger.new }\n\n    subject(:result) { described_class.find_from_oidc(auth, logger: logger) }\n\n    context \"when there is a user that matchers the UID and issuer\" do\n      before do\n        @existing_user = create(:user, oidc_uid: uid, oidc_issuer: issuer, first_name: \"mary\",\n                                       last_name: \"apples\", email_address: \"mary@apples.com\")\n      end\n\n      it \"returns that user\" do\n        expect(result).to eq @existing_user\n      end\n\n      it \"updates the name and email address\" do\n        result\n        @existing_user.reload\n        expect(@existing_user.first_name).to eq \"John\"\n        expect(@existing_user.last_name).to eq \"Smith\"\n        expect(@existing_user.email_address).to eq \"test@example.com\"\n      end\n\n      it \"logs\" do\n        result\n        expect(logger).to have_logged(/found user with UID abcdef/i)\n      end\n    end\n\n    context \"when there is no user which matches the UID and issuer\" do\n      context \"when there is a user which matches the email address without an OIDC UID\" do\n        before do\n          @existing_user = create(:user, first_name: \"mary\",\n                                         last_name: \"apples\", email_address: \"test@example.com\")\n        end\n\n        it \"returns that user\" do\n          expect(result).to eq @existing_user\n        end\n\n        it \"adds the UID and issuer to the user\" do\n          result\n          @existing_user.reload\n          expect(@existing_user.oidc_uid).to eq uid\n          expect(@existing_user.oidc_issuer).to eq issuer\n        end\n\n        it \"updates the name if changed\" do\n          result\n          @existing_user.reload\n          expect(@existing_user.first_name).to eq \"John\"\n          expect(@existing_user.last_name).to eq \"Smith\"\n        end\n\n        it \"removes the password\" do\n          @existing_user.password = \"password\"\n          @existing_user.save!\n          result\n          @existing_user.reload\n          expect(@existing_user.password_digest).to be_nil\n        end\n\n        it \"logs\" do\n          result\n          expect(logger).to have_logged(/no user with UID abcdef/)\n          expect(logger).to have_logged(/found user with e-mail address test@example.com/)\n        end\n      end\n\n      context \"when there is no user which matches the email address\" do\n        it \"returns nil\" do\n          expect(result).to be_nil\n        end\n\n        it \"logs\" do\n          result\n          expect(logger).to have_logged(/no user with UID abcdef/)\n          expect(logger).to have_logged(/no user with e-mail address/)\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/models/user_spec.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: users\n#\n#  id                               :integer          not null, primary key\n#  admin                            :boolean          default(FALSE)\n#  email_address                    :string(255)\n#  email_verification_token         :string(255)\n#  email_verified_at                :datetime\n#  first_name                       :string(255)\n#  last_name                        :string(255)\n#  oidc_issuer                      :string(255)\n#  oidc_uid                         :string(255)\n#  password_digest                  :string(255)\n#  password_reset_token             :string(255)\n#  password_reset_token_valid_until :datetime\n#  time_zone                        :string(255)\n#  uuid                             :string(255)\n#  created_at                       :datetime\n#  updated_at                       :datetime\n#\n# Indexes\n#\n#  index_users_on_email_address  (email_address)\n#  index_users_on_uuid           (uuid)\n#\nrequire \"rails_helper\"\n\ndescribe User do\n  subject(:user) { build(:user) }\n\n  describe \"validations\" do\n    it { is_expected.to validate_presence_of(:first_name) }\n    it { is_expected.to validate_presence_of(:last_name) }\n    it { is_expected.to validate_presence_of(:email_address) }\n    it { is_expected.to validate_presence_of(:password) }\n    it { is_expected.to validate_uniqueness_of(:email_address).case_insensitive }\n    it { is_expected.to allow_value(\"test@example.com\").for(:email_address) }\n    it { is_expected.to allow_value(\"test@example.co.uk\").for(:email_address) }\n    it { is_expected.to allow_value(\"test+tagged@example.co.uk\").for(:email_address) }\n    it { is_expected.to allow_value(\"test+tagged@EXAMPLE.COM\").for(:email_address) }\n    it { is_expected.to_not allow_value(\"test+tagged\").for(:email_address) }\n    it { is_expected.to_not allow_value(\"test.com\").for(:email_address) }\n\n    it \"does not require a password when OIDC is enabled\" do\n      allow(Postal::Config.oidc).to receive(:enabled?).and_return(true)\n      user.password = nil\n      expect(user.save).to be true\n    end\n  end\n\n  describe \"relationships\" do\n    it { is_expected.to have_many(:organization_users) }\n    it { is_expected.to have_many(:organizations) }\n  end\n\n  describe \"creation\" do\n    before { user.save }\n\n    it \"should have a UUID\" do\n      expect(user.uuid).to be_a String\n      expect(user.uuid.length).to eq 36\n    end\n\n    it \"has a default timezone\" do\n      expect(user.time_zone).to eq \"UTC\"\n    end\n  end\n\n  describe \"#organizations_scope\" do\n    context \"when the user is an admin\" do\n      it \"returns a scope of all organizations\" do\n        user.admin = true\n        scope = user.organizations_scope\n        expect(scope).to eq Organization.present\n      end\n    end\n\n    context \"when the user not an admin\" do\n      it \"returns a scope including only orgs the user is associated with\" do\n        user.admin = false\n        user.organizations << create(:organization)\n        scope = user.organizations_scope\n        expect(scope).to eq user.organizations.present\n      end\n    end\n  end\n\n  describe \"#name\" do\n    it \"returns the name\" do\n      user.first_name = \"John\"\n      user.last_name = \"Doe\"\n      expect(user.name).to eq \"John Doe\"\n    end\n  end\n\n  describe \"#password?\" do\n    it \"returns true if the user has a password\" do\n      user.password = \"password\"\n      expect(user.password?).to be true\n    end\n\n    it \"returns false if the user does not have a password\" do\n      user.password = nil\n      expect(user.password?).to be false\n    end\n  end\n\n  describe \"#to_param\" do\n    it \"returns the UUID\" do\n      user.uuid = \"123\"\n      expect(user.to_param).to eq \"123\"\n    end\n  end\n\n  describe \"#email_tag\" do\n    it \"returns the name and email address\" do\n      user.first_name = \"John\"\n      user.last_name = \"Doe\"\n      user.email_address = \"john@example.com\"\n      expect(user.email_tag).to eq \"John Doe <john@example.com>\"\n    end\n  end\n\n  describe \".[]\" do\n    it \"should find a user by email address\" do\n      user = create(:user)\n      expect(User[user.email_address]).to eq user\n    end\n  end\nend\n"
  },
  {
    "path": "spec/models/worker_role_spec.rb",
    "content": "# frozen_string_literal: true\n\n# == Schema Information\n#\n# Table name: worker_roles\n#\n#  id          :bigint           not null, primary key\n#  acquired_at :datetime\n#  role        :string(255)\n#  worker      :string(255)\n#\n# Indexes\n#\n#  index_worker_roles_on_role  (role) UNIQUE\n#\nrequire \"rails_helper\"\n\nRSpec.describe WorkerRole do\n  let(:locker_name) { \"test\" }\n\n  before do\n    allow(Postal).to receive(:locker_name).and_return(locker_name)\n  end\n\n  describe \".acquire\" do\n    context \"when there are no existing roles\" do\n      it \"returns :created\" do\n        expect(WorkerRole.acquire(\"test\")).to eq(:created)\n      end\n    end\n\n    context \"when the current process holds a lock for a role\" do\n      it \"returns :renewed\" do\n        create(:worker_role, role: \"test\", worker: \"test\", acquired_at: 1.minute.ago)\n        expect(WorkerRole.acquire(\"test\")).to eq(:renewed)\n      end\n    end\n\n    context \"when the role has become stale\" do\n      it \"returns :stolen\" do\n        create(:worker_role, role: \"test\", worker: \"another\", acquired_at: 10.minute.ago)\n        expect(WorkerRole.acquire(\"test\")).to eq(:stolen)\n      end\n    end\n\n    context \"when the role is already locked by another worker\" do\n      it \"returns false\" do\n        create(:worker_role, role: \"test\", worker: \"another\", acquired_at: 1.minute.ago)\n        expect(WorkerRole.acquire(\"test\")).to eq(false)\n      end\n    end\n  end\n\n  describe \".release\" do\n    context \"when the role is locked by the current worker\" do\n      it \"deletes the role and returns true\" do\n        role = create(:worker_role, role: \"test\", worker: \"test\")\n        expect(WorkerRole.release(\"test\")).to eq(true)\n        expect(WorkerRole.find_by(id: role.id)).to be_nil\n      end\n    end\n\n    context \"when the role is locked by another worker\" do\n      it \"does not delete the role and returns false\" do\n        role = create(:worker_role, role: \"test\", worker: \"another\")\n        expect(WorkerRole.release(\"test\")).to eq(false)\n        expect(WorkerRole.find_by(id: role.id)).to be_present\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/rails_helper.rb",
    "content": "# frozen_string_literal: true\n\nENV[\"POSTAL_CONFIG_FILE_PATH\"] ||= \"config/postal/postal.test.yml\"\n\nrequire \"dotenv\"\nDotenv.load(\".env.test\")\n\nrequire File.expand_path(\"../config/environment\", __dir__)\nrequire \"rspec/rails\"\nrequire \"spec_helper\"\nrequire \"factory_bot\"\nrequire \"timecop\"\nrequire \"webmock/rspec\"\nrequire \"shoulda-matchers\"\n\nDatabaseCleaner.allow_remote_database_url = true\nActiveRecord::Base.logger = Logger.new(\"/dev/null\")\n\nDir[File.expand_path(\"helpers/**/*.rb\", __dir__)].each { |f| require f }\n\nActionMailer::Base.delivery_method = :test\n\nActiveRecord::Migration.maintain_test_schema!\n\nShoulda::Matchers.configure do |config|\n  config.integrate do |with|\n    with.test_framework :rspec\n    with.library :rails\n  end\nend\n\nRSpec.configure do |config|\n  config.use_transactional_fixtures = true\n  config.infer_spec_type_from_file_location!\n  config.include FactoryBot::Syntax::Methods\n  config.include GeneralHelpers\n\n  # Before all request specs, set the hostname to the web hostname for\n  # Postal otherwise it'll be www.example.com which will fail host\n  # authorization checks.\n  config.before(:each, type: :request) do\n    host! Postal::Config.postal.web_hostname\n  end\n\n  # Test that the factories are working as they should and then clean up before getting started on\n  # the rest of the suite.\n  config.before(:suite) do\n    DatabaseCleaner.start\n    FactoryBot.lint\n  ensure\n    DatabaseCleaner.clean\n  end\nend\n"
  },
  {
    "path": "spec/scheduled_tasks/tidy_queued_messages_task_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nRSpec.describe TidyQueuedMessagesTask do\n  let(:logger) { TestLogger.new }\n\n  subject(:task) { described_class.new(logger: logger) }\n\n  describe \"#call\" do\n    it \"destroys queued messages with stale locks\" do\n      stale_message = create(:queued_message, locked_at: 2.days.ago, locked_by: \"test\")\n      task.call\n      expect { stale_message.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      expect(logger).to have_logged(/removing queued message \\d+/)\n    end\n\n    it \"does not destroy messages which are not locked\" do\n      message = create(:queued_message)\n      task.call\n      expect { message.reload }.not_to raise_error\n    end\n\n    it \"does not destroy messages which where were locked less then the number of stale days\" do\n      message = create(:queued_message, locked_at: 10.minutes.ago, locked_by: \"test\")\n      task.call\n      expect { message.reload }.not_to raise_error\n    end\n  end\nend\n"
  },
  {
    "path": "spec/senders/smtp_sender_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nRSpec.describe SMTPSender do\n  subject(:sender) { described_class.new(\"example.com\") }\n\n  let(:smtp_start_error) { nil }\n  let(:smtp_send_message_error) { nil }\n  let(:smtp_send_message_result) { double(\"Result\", string: \"accepted\") }\n\n  before do\n    # Mock the SMTP client endpoint so that we can avoid making any actual\n    # SMTP connections but still mock things as appropriate.\n    allow(SMTPClient::Endpoint).to receive(:new).and_wrap_original do |original, *args, **kwargs|\n      endpoint = original.call(*args, **kwargs)\n\n      allow(endpoint).to receive(:start_smtp_session) do |**ikwargs|\n        if error = smtp_start_error&.call(endpoint, ikwargs[:allow_ssl])\n          raise error\n        end\n      end\n\n      allow(endpoint).to receive(:send_message) do |message|\n        if error = smtp_send_message_error&.call(endpoint, message)\n          raise error\n        end\n\n        smtp_send_message_result\n      end\n      allow(endpoint).to receive(:finish_smtp_session)\n      allow(endpoint).to receive(:reset_smtp_session)\n      allow(endpoint).to receive(:smtp_client) do\n        Net::SMTP.new(endpoint.ip_address, endpoint.server.port)\n      end\n      endpoint\n    end\n  end\n\n  before do\n    # Override the DNS resolver to return empty arrays by default for A and AAAA\n    # DNS lookups to avoid making requests to public servers.\n    allow(DNSResolver.local).to receive(:aaaa).and_return([])\n    allow(DNSResolver.local).to receive(:a).and_return([])\n  end\n\n  describe \"#start\" do\n    context \"when no servers are provided to the class and there are no SMTP relays\" do\n      context \"when there are MX records\" do\n        before do\n          allow(DNSResolver.local).to receive(:mx).and_return([[5, \"mx1.example.com\"], [10, \"mx2.example.com\"]])\n          allow(DNSResolver.local).to receive(:a).with(\"mx1.example.com\").and_return([\"1.2.3.4\"])\n          allow(DNSResolver.local).to receive(:a).with(\"mx2.example.com\").and_return([\"6.7.8.9\"])\n        end\n\n        it \"attempts to create an SMTP connection for each endpoint for each MX server for them\" do\n          endpoint = sender.start\n          expect(endpoint).to be_a SMTPClient::Endpoint\n          expect(endpoint).to have_attributes(\n            ip_address: \"1.2.3.4\",\n            server: have_attributes(hostname: \"mx1.example.com\", port: 25, ssl_mode: SMTPClient::SSLModes::AUTO)\n          )\n        end\n      end\n\n      context \"when there are no MX records\" do\n        before do\n          allow(DNSResolver.local).to receive(:mx).and_return([])\n          allow(DNSResolver.local).to receive(:a).with(\"example.com\").and_return([\"1.2.3.4\"])\n        end\n\n        it \"attempts to create an SMTP connection for the domain itself\" do\n          endpoint = sender.start\n          expect(endpoint).to be_a SMTPClient::Endpoint\n          expect(endpoint).to have_attributes(\n            ip_address: \"1.2.3.4\",\n            server: have_attributes(hostname: \"example.com\", port: 25, ssl_mode: SMTPClient::SSLModes::AUTO)\n          )\n        end\n      end\n\n      context \"when the MX lookup times out\" do\n        before do\n          allow(DNSResolver.local).to receive(:mx).and_raise(Resolv::ResolvError.new(\"DNS resolv timeout: example.com\"))\n          allow(DNSResolver.local).to receive(:a).with(\"example.com\").and_return([\"1.2.3.4\"])\n        end\n\n        it \"raises an error\" do\n          expect { sender.start }.to raise_error Resolv::ResolvError\n        end\n      end\n    end\n\n    context \"when there are no servers provided to the class but there are SMTP relays\" do\n      before do\n        allow(SMTPSender).to receive(:smtp_relays).and_return([SMTPClient::Server.new(\"relay.example.com\", port: 2525, ssl_mode: SMTPClient::SSLModes::TLS)])\n        allow(DNSResolver.local).to receive(:a).with(\"relay.example.com\").and_return([\"1.2.3.4\"])\n      end\n\n      it \"attempts to use the relays\" do\n        endpoint = sender.start\n        expect(endpoint).to be_a SMTPClient::Endpoint\n        expect(endpoint).to have_attributes(\n          ip_address: \"1.2.3.4\",\n          server: have_attributes(hostname: \"relay.example.com\", port: 2525, ssl_mode: SMTPClient::SSLModes::TLS)\n        )\n      end\n    end\n\n    context \"when there are servers provided to the class\" do\n      let(:server) { SMTPClient::Server.new(\"custom.example.com\") }\n\n      subject(:sender) { described_class.new(\"example.com\", servers: [server]) }\n\n      before do\n        allow(DNSResolver.local).to receive(:a).with(\"custom.example.com\").and_return([\"1.2.3.4\"])\n      end\n\n      it \"uses the provided servers\" do\n        endpoint = sender.start\n        expect(endpoint).to be_a SMTPClient::Endpoint\n        expect(endpoint).to have_attributes(\n          ip_address: \"1.2.3.4\",\n          server: server\n        )\n      end\n    end\n\n    context \"when a source IP is given without IPv6 and an endpoint is IPv6 enabled\" do\n      let(:source_ip_address) { create(:ip_address, ipv6: nil) }\n      let(:server) { SMTPClient::Server.new(\"custom.example.com\") }\n      subject(:sender) { described_class.new(\"example.com\", source_ip_address, servers: [server]) }\n\n      before do\n        allow(DNSResolver.local).to receive(:aaaa).with(\"custom.example.com\").and_return([\"2a00:67a0:a::1\"])\n        allow(DNSResolver.local).to receive(:a).with(\"custom.example.com\").and_return([\"1.2.3.4\"])\n      end\n\n      it \"returns the IPv4 version\" do\n        endpoint = sender.start\n        expect(endpoint).to be_a SMTPClient::Endpoint\n        expect(endpoint).to have_attributes(\n          ip_address: \"1.2.3.4\",\n          server: server\n        )\n      end\n    end\n\n    context \"when there are no servers to connect to\" do\n      it \"returns false\" do\n        expect(sender.start).to be false\n      end\n    end\n\n    context \"when the first server tried cannot be connected to\" do\n      let(:server1) { SMTPClient::Server.new(\"custom1.example.com\") }\n      let(:server2) { SMTPClient::Server.new(\"custom2.example.com\") }\n\n      let(:smtp_start_error) do\n        proc do |endpoint|\n          Errno::ECONNREFUSED if endpoint.ip_address == \"1.2.3.4\"\n        end\n      end\n\n      before do\n        allow(DNSResolver.local).to receive(:a).with(\"custom1.example.com\").and_return([\"1.2.3.4\"])\n        allow(DNSResolver.local).to receive(:a).with(\"custom2.example.com\").and_return([\"2.3.4.5\"])\n      end\n\n      subject(:sender) { described_class.new(\"example.com\", servers: [server1, server2]) }\n\n      it \"tries the second\" do\n        endpoint = sender.start\n        expect(endpoint).to be_a SMTPClient::Endpoint\n        expect(endpoint).to have_attributes(\n          ip_address: \"2.3.4.5\",\n          server: have_attributes(hostname: \"custom2.example.com\")\n        )\n      end\n\n      it \"includes both endpoints in the array of endpoints tried\" do\n        sender.start\n        expect(sender.endpoints).to match([\n                                            have_attributes(ip_address: \"1.2.3.4\"),\n                                            have_attributes(ip_address: \"2.3.4.5\"),\n                                          ])\n      end\n    end\n\n    context \"when the server returns an SSL error and SSL mode is Auto\" do\n      let(:server) { SMTPClient::Server.new(\"custom.example.com\") }\n\n      let(:smtp_start_error) do\n        proc do |endpoint, allow_ssl|\n          OpenSSL::SSL::SSLError if allow_ssl && endpoint.server.ssl_mode == \"Auto\"\n        end\n      end\n\n      before do\n        allow(DNSResolver.local).to receive(:aaaa).with(\"custom.example.com\").and_return([])\n        allow(DNSResolver.local).to receive(:a).with(\"custom.example.com\").and_return([\"1.2.3.4\"])\n      end\n\n      subject(:sender) { described_class.new(\"example.com\", servers: [server]) }\n\n      it \"attempts to reconnect without SSL\" do\n        endpoint = sender.start\n        expect(endpoint).to be_a SMTPClient::Endpoint\n        expect(endpoint).to have_attributes(ip_address: \"1.2.3.4\")\n      end\n    end\n  end\n\n  describe \"#send_message\" do\n    let(:server) { create(:server) }\n    let(:domain) { create(:domain, server: server) }\n    let(:dns_result) { [] }\n    let(:message) { MessageFactory.outgoing(server, domain: domain) }\n\n    let(:smtp_client_server) { SMTPClient::Server.new(\"mx1.example.com\") }\n    subject(:sender) { described_class.new(\"example.com\", servers: [smtp_client_server]) }\n\n    before do\n      allow(DNSResolver.local).to receive(:a).with(\"mx1.example.com\").and_return(dns_result)\n      sender.start\n    end\n\n    context \"when there is no current endpoint to use\" do\n      it \"returns a SoftFail\" do\n        result = sender.send_message(message)\n        expect(result).to be_a SendResult\n        expect(result).to have_attributes(\n          type: \"SoftFail\",\n          retry: true,\n          output: \"\",\n          details: /No SMTP servers were available for example.com. No hosts to try./,\n          connect_error: true\n        )\n      end\n    end\n\n    context \"when there is an endpoint\" do\n      let(:dns_result) { [\"1.2.3.4\"] }\n\n      context \"it sends the message to the endpoint\" do\n        context \"if the message is a bounce\" do\n          let(:message) { MessageFactory.outgoing(server, domain: domain) { |m| m.bounce = true } }\n\n          it \"sends an empty MAIL FROM\" do\n            sender.send_message(message)\n            expect(sender.endpoints.last).to have_received(:send_message).with(\n              kind_of(String),\n              \"\",\n              [\"john@example.com\"]\n            )\n          end\n        end\n\n        context \"if the domain has a valid custom return path\" do\n          let(:domain) { create(:domain, return_path_status: \"OK\") }\n\n          it \"sends the custom return path as MAIL FROM\" do\n            sender.send_message(message)\n            expect(sender.endpoints.last).to have_received(:send_message).with(\n              kind_of(String),\n              \"#{server.token}@#{domain.return_path_domain}\",\n              [\"john@example.com\"]\n            )\n          end\n        end\n\n        context \"if the domain has no valid custom return path\" do\n          it \"sends the server default return path as MAIL FROM\" do\n            sender.send_message(message)\n            expect(sender.endpoints.last).to have_received(:send_message).with(\n              kind_of(String),\n              \"#{server.token}@#{Postal::Config.dns.return_path_domain}\",\n              [\"john@example.com\"]\n            )\n          end\n        end\n\n        context \"if the sender has specified an RCPT TO\" do\n          subject(:sender) { described_class.new(\"example.com\", servers: [smtp_client_server], rcpt_to: \"custom@example.com\") }\n\n          it \"sends the specified RCPT TO\" do\n            sender.send_message(message)\n            expect(sender.endpoints.last).to have_received(:send_message).with(\n              kind_of(String),\n              kind_of(String),\n              [\"custom@example.com\"]\n            )\n          end\n        end\n\n        context \"if the sender has not specified an RCPT TO\" do\n          it \"uses the RCPT TO from the message\" do\n            sender.send_message(message)\n            expect(sender.endpoints.last).to have_received(:send_message).with(\n              kind_of(String),\n              kind_of(String),\n              [\"john@example.com\"]\n            )\n          end\n        end\n\n        context \"if the configuration says to add the Resent-Sender header\" do\n          it \"adds the resent-sender header\" do\n            sender.send_message(message)\n            expect(sender.endpoints.last).to have_received(:send_message).with(\n              \"Resent-Sender: #{server.token}@#{Postal::Config.dns.return_path_domain}\\r\\n#{message.raw_message}\",\n              kind_of(String),\n              kind_of(Array)\n            )\n          end\n        end\n\n        context \"if the configuration says to not add the Resent-From header\" do\n          before do\n            allow(Postal::Config.postal).to receive(:use_resent_sender_header?).and_return(false)\n          end\n\n          it \"does not add the resent-from header\" do\n            sender.send_message(message)\n            expect(sender.endpoints.last).to have_received(:send_message).with(\n              message.raw_message,\n              kind_of(String),\n              kind_of(Array)\n            )\n          end\n        end\n      end\n\n      context \"when the message is accepted\" do\n        it \"returns a Sent result\" do\n          result = sender.send_message(message)\n          expect(result).to be_a SendResult\n          expect(result).to have_attributes(\n            type: \"Sent\",\n            details: \"Message for john@example.com accepted by 1.2.3.4:25 (mx1.example.com)\",\n            output: \"accepted\"\n          )\n        end\n      end\n\n      context \"when SMTP server is busy\" do\n        let(:smtp_send_message_error) { proc { Net::SMTPServerBusy.new(\"SMTP server was busy\") } }\n\n        it \"returns a SoftFail\" do\n          result = sender.send_message(message)\n          expect(result).to be_a SendResult\n          expect(result).to have_attributes(\n            type: \"SoftFail\",\n            retry: true,\n            details: /Temporary SMTP delivery error when sending/\n          )\n        end\n\n        it \"resets the endpoint SMTP sesssion\" do\n          sender.send_message(message)\n          expect(sender.endpoints.last).to have_received(:reset_smtp_session)\n        end\n      end\n\n      context \"when the SMTP server returns an error if a retry time in seconds\" do\n        let(:smtp_send_message_error) { proc { Net::SMTPServerBusy.new(\"Try again in 30 seconds\") } }\n\n        it \"returns a SoftFail with the retry time from the error\" do\n          result = sender.send_message(message)\n          expect(result).to be_a SendResult\n          expect(result).to have_attributes(\n            type: \"SoftFail\",\n            retry: 40\n          )\n        end\n      end\n\n      context \"when the SMTP server returns an error if a retry time in minutes\" do\n        let(:smtp_send_message_error) { proc { Net::SMTPServerBusy.new(\"Try again in 5 minutes\") } }\n\n        it \"returns a SoftFail with the retry time from the error\" do\n          result = sender.send_message(message)\n          expect(result).to be_a SendResult\n          expect(result).to have_attributes(\n            type: \"SoftFail\",\n            retry: 310\n          )\n        end\n      end\n\n      context \"when there is an SMTP authentication error\" do\n        let(:smtp_send_message_error) { proc { Net::SMTPAuthenticationError.new(\"Denied\") } }\n\n        it \"returns a SoftFail\" do\n          result = sender.send_message(message)\n          expect(result).to be_a SendResult\n          expect(result).to have_attributes(\n            type: \"SoftFail\",\n            details: /Temporary SMTP delivery error when sending/\n          )\n        end\n\n        it \"resets the endpoint SMTP sesssion\" do\n          sender.send_message(message)\n          expect(sender.endpoints.last).to have_received(:reset_smtp_session)\n        end\n      end\n\n      context \"when there is a timeout\" do\n        let(:smtp_send_message_error) { proc { Net::ReadTimeout.new } }\n\n        it \"returns a SoftFail\" do\n          result = sender.send_message(message)\n          expect(result).to be_a SendResult\n          expect(result).to have_attributes(\n            type: \"SoftFail\",\n            details: /Temporary SMTP delivery error when sending/\n          )\n        end\n\n        it \"resets the endpoint SMTP sesssion\" do\n          sender.send_message(message)\n          expect(sender.endpoints.last).to have_received(:reset_smtp_session)\n        end\n      end\n\n      context \"when there is an SMTP syntax error\" do\n        let(:smtp_send_message_error) { proc { Net::SMTPSyntaxError.new(\"Syntax error\") } }\n\n        it \"returns a SoftFail\" do\n          result = sender.send_message(message)\n          expect(result).to be_a SendResult\n          expect(result).to have_attributes(\n            type: \"SoftFail\",\n            output: \"Syntax error\",\n            details: /Temporary SMTP delivery error when sending/\n          )\n        end\n\n        it \"resets the endpoint SMTP sesssion\" do\n          sender.send_message(message)\n          expect(sender.endpoints.last).to have_received(:reset_smtp_session)\n        end\n      end\n\n      context \"when there is an unknown SMTP error\" do\n        let(:smtp_send_message_error) { proc { Net::SMTPUnknownError.new(\"unknown error\") } }\n\n        it \"returns a SoftFail\" do\n          result = sender.send_message(message)\n          expect(result).to be_a SendResult\n          expect(result).to have_attributes(\n            type: \"SoftFail\",\n            output: \"unknown error\",\n            details: /Temporary SMTP delivery error when sending/\n          )\n        end\n\n        it \"resets the endpoint SMTP sesssion\" do\n          sender.send_message(message)\n          expect(sender.endpoints.last).to have_received(:reset_smtp_session)\n        end\n      end\n\n      context \"when there is an fatal SMTP error\" do\n        let(:smtp_send_message_error) { proc { Net::SMTPFatalError.new(\"fatal error\") } }\n\n        it \"returns a HardFail\" do\n          result = sender.send_message(message)\n          expect(result).to be_a SendResult\n          expect(result).to have_attributes(\n            type: \"HardFail\",\n            output: \"fatal error\",\n            details: /Permanent SMTP delivery error when sending/\n          )\n        end\n\n        it \"resets the endpoint SMTP sesssion\" do\n          sender.send_message(message)\n          expect(sender.endpoints.last).to have_received(:reset_smtp_session)\n        end\n      end\n\n      context \"when there is an unexpected error\" do\n        let(:smtp_send_message_error) { proc { ZeroDivisionError.new(\"divided by 0\") } }\n\n        it \"returns a SoftFail\" do\n          result = sender.send_message(message)\n          expect(result).to be_a SendResult\n          expect(result).to have_attributes(\n            type: \"SoftFail\",\n            output: \"divided by 0\",\n            details: /An error occurred while sending the message/\n          )\n        end\n\n        it \"resets the endpoint SMTP sesssion\" do\n          sender.send_message(message)\n          expect(sender.endpoints.last).to have_received(:reset_smtp_session)\n        end\n      end\n    end\n  end\n\n  describe \"#finish\" do\n    let(:server) { SMTPClient::Server.new(\"custom.example.com\") }\n\n    subject(:sender) { described_class.new(\"example.com\", servers: [server]) }\n\n    let(:smtp_start_error) do\n      proc do |endpoint|\n        Errno::ECONNREFUSED if endpoint.ip_address == \"1.2.3.4\"\n      end\n    end\n\n    before do\n      allow(DNSResolver.local).to receive(:a).with(\"custom.example.com\").and_return([\"1.2.3.4\", \"2.3.4.5\"])\n      sender.start\n    end\n\n    it \"calls finish_smtp_session on all endpoints\" do\n      sender.finish\n      expect(sender.endpoints.size).to eq 2\n      expect(sender.endpoints).to all have_received(:finish_smtp_session).at_least(:once)\n    end\n  end\n\n  describe \".smtp_relays\" do\n    before do\n      if described_class.instance_variable_defined?(\"@smtp_relays\")\n        described_class.remove_instance_variable(\"@smtp_relays\")\n      end\n    end\n\n    it \"returns nil if smtp relays is nil\" do\n      allow(Postal::Config.postal).to receive(:smtp_relays).and_return(nil)\n      expect(described_class.smtp_relays).to be nil\n    end\n\n    it \"returns nil if there are no smtp relays\" do\n      allow(Postal::Config.postal).to receive(:smtp_relays).and_return([])\n      expect(described_class.smtp_relays).to be nil\n    end\n\n    it \"does not return relays where the host is nil\" do\n      allow(Postal::Config.postal).to receive(:smtp_relays).and_return([\n                                                                         Hashie::Mash.new(host: nil, port: 25, ssl_mode: \"Auto\"),\n                                                                         Hashie::Mash.new(host: \"test.example.com\", port: 25, ssl_mode: \"Auto\"),\n                                                                       ])\n      expect(described_class.smtp_relays).to match [kind_of(SMTPClient::Server)]\n    end\n\n    it \"returns relays with options\" do\n      allow(Postal::Config.postal).to receive(:smtp_relays).and_return([\n                                                                         Hashie::Mash.new(host: \"test.example.com\", port: 25, ssl_mode: \"Auto\"),\n                                                                         Hashie::Mash.new(host: \"test2.example.com\", port: 2525, ssl_mode: \"TLS\"),\n                                                                       ])\n      expect(described_class.smtp_relays).to match [\n        have_attributes(hostname: \"test.example.com\", port: 25, ssl_mode: \"Auto\"),\n        have_attributes(hostname: \"test2.example.com\", port: 2525, ssl_mode: \"TLS\"),\n      ]\n    end\n  end\nend\n"
  },
  {
    "path": "spec/services/webhook_delivery_service_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails_helper\"\n\nRSpec.describe WebhookDeliveryService do\n  let(:server) { create(:server) }\n  let(:webhook) { create(:webhook, server: server) }\n  let(:webhook_request) { create(:webhook_request, :locked, webhook: webhook) }\n\n  subject(:service) { described_class.new(webhook_request: webhook_request) }\n\n  let(:response_status) { 200 }\n  let(:response_body) { \"OK\" }\n\n  before do\n    stub_request(:post, webhook.url).to_return(status: response_status, body: response_body)\n  end\n\n  describe \"#call\" do\n    it \"sends a request to the webhook's url\" do\n      service.call\n      expect(WebMock).to have_requested(:post, webhook.url).with({\n        body: {\n          event: webhook_request.event,\n          timestamp: webhook_request.created_at.to_f,\n          payload: webhook_request.payload,\n          uuid: webhook_request.uuid\n        }.to_json,\n        headers: {\n          \"Content-Type\" => \"application/json\",\n          \"X-Postal-Signature\" => /\\A[a-z0-9\\/+]+=*\\z/i,\n          \"X-Postal-Signature-256\" => /\\A[a-z0-9\\/+]+=*\\z/i,\n          \"X-Postal-Signature-KID\" => /\\A[a-f0-9\\/+]{64}\\z/i\n        }\n      })\n    end\n\n    context \"when the endpoint returns a 200 OK\" do\n      it \"creates a webhook request for the server\" do\n        service.call\n        expect(server.message_db.webhooks.list(1)[:total]).to eq(1)\n        webhook_request = server.message_db.webhooks.list(1)[:records].first\n        expect(webhook_request).to have_attributes(\n          event: webhook_request.event,\n          url: webhook_request.url,\n          status_code: 200,\n          body: \"OK\",\n          uuid: webhook_request.uuid,\n          will_retry?: false,\n          payload: webhook_request.payload,\n          attempt: 1,\n          timestamp: webhook_request.timestamp\n        )\n      end\n\n      it \"deletes the webhook request\" do\n        service.call\n        expect { webhook_request.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n\n      it \"updates the last used at time on the webhook\" do\n        frozen_time = Time.current.change(usec: 0)\n        Timecop.freeze(frozen_time) do\n          service.call\n          expect(webhook.reload.last_used_at).to eq(frozen_time)\n        end\n      end\n    end\n\n    context \"when the request returns a 500 Internal Server Error for the first time\" do\n      let(:response_status) { 500 }\n      let(:response_body) { \"internal server error!\" }\n\n      it \"unlocks the webhook request if locked\" do\n        expect { service.call }.to change { webhook_request.reload.locked? }.from(true).to(false)\n      end\n\n      it \"updates the retry time and attempt counter\" do\n        service.call\n        expect(webhook_request.reload.attempts).to eq(1)\n        expect(webhook_request.retry_after).to be_within(1.second).of(2.minutes.from_now)\n      end\n    end\n\n    context \"when the request returns a 500 Internal Server Error for the second time\" do\n      let(:webhook_request) { create(:webhook_request, :locked, webhook: webhook, attempts: 1) }\n      let(:response_status) { 500 }\n      let(:response_body) { \"internal server error!\" }\n\n      it \"updates the retry time and attempt counter\" do\n        service.call\n        expect(webhook_request.reload.attempts).to eq(2)\n        expect(webhook_request.retry_after).to be_within(1.second).of(3.minutes.from_now)\n      end\n    end\n\n    context \"when the request returns a 500 Internal Server Error for the sixth time\" do\n      let(:webhook_request) { create(:webhook_request, :locked, webhook: webhook, attempts: 5) }\n      let(:response_status) { 500 }\n      let(:response_body) { \"internal server error!\" }\n\n      it \"creates a webhook request for the server\" do\n        service.call\n        expect(server.message_db.webhooks.list(1)[:total]).to eq(1)\n        webhook_request = server.message_db.webhooks.list(1)[:records].first\n        expect(webhook_request).to have_attributes(\n          status_code: 500,\n          body: \"internal server error!\",\n          will_retry?: false,\n          attempt: 6\n        )\n      end\n\n      it \"deletes the webhook request\" do\n        service.call\n        expect { webhook_request.reload }.to raise_error(ActiveRecord::RecordNotFound)\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/spec_helper.rb",
    "content": "# frozen_string_literal: true\n\nRSpec.configure do |config|\n  config.color = true\n\n  config.expect_with :rspec do |expectations|\n    expectations.include_chain_clauses_in_custom_matcher_descriptions = true\n  end\n\n  config.mock_with :rspec do |mocks|\n    mocks.verify_partial_doubles = true\n  end\nend\n"
  },
  {
    "path": "tmp/.keep",
    "content": ""
  },
  {
    "path": "vendor/assets/javascripts/.keep",
    "content": ""
  },
  {
    "path": "vendor/assets/stylesheets/.keep",
    "content": ""
  }
]