mirror of
https://github.com/postalserver/postal.git
synced 2026-10-18 18:26:06 +00:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eeed7c023 | ||
|
|
ced77a3860 | ||
|
|
3be663dc89 | ||
|
|
d038eaa8c7 | ||
|
|
3b3defe271 | ||
|
|
029bfe098d | ||
|
|
0445e5c509 | ||
|
|
11c9814474 | ||
|
|
4314a6ec1e | ||
|
|
8be1e27fec | ||
|
|
8ef89606bc | ||
|
|
84f4e20f05 | ||
|
|
9243524035 | ||
|
|
dca7f90b90 | ||
|
|
cad2aa6808 | ||
|
|
b611d577af | ||
|
|
d532922ff7 | ||
|
|
11419f9914 | ||
|
|
b7e5232e07 | ||
|
|
e00098b800 | ||
|
|
d00d978872 | ||
|
|
c78000ca8f | ||
|
|
c03c44b442 | ||
|
|
86de372382 | ||
|
|
7c47422c86 | ||
|
|
f5325c49ff | ||
|
|
f193b8e77f | ||
|
|
ab6d4430ba | ||
|
|
9c5f96ae90 | ||
|
|
fd3c7ccdf6 | ||
|
|
da90e75036 | ||
|
|
2b0919c145 | ||
|
|
3a33e53d84 | ||
|
|
4fa88acea0 | ||
|
|
d510499190 | ||
|
|
39f704c256 | ||
|
|
c12f30e300 | ||
|
|
5a2f31ed77 | ||
|
|
07c6b317f2 | ||
|
|
a3fab36da2 | ||
|
|
3a56ec8a74 | ||
|
|
b3264b9427 | ||
|
|
6ef388577e | ||
|
|
18236171eb | ||
|
|
bee509832e | ||
|
|
4d9654dac4 | ||
|
|
adaf2b0750 | ||
|
|
64bc7dcf7c | ||
|
|
d65bbe0579 | ||
|
|
eded789c37 | ||
|
|
ea542a0694 | ||
|
|
7e2acccd1e | ||
|
|
ee8d829a85 | ||
|
|
4fcb9e9a2e | ||
|
|
45dd8aaac5 | ||
|
|
364eba6c5f | ||
|
|
6a1ff56fe2 | ||
|
|
be456523dd | ||
|
|
3d208d632f | ||
|
|
1c67f72209 | ||
|
|
aa76aae232 | ||
|
|
f760cdb5a1 | ||
|
|
b55becd2ec | ||
|
|
92406129cf | ||
|
|
144af20b9c | ||
|
|
58dddebeb8 | ||
|
|
8e71dd0232 | ||
|
|
96d73653d7 | ||
|
|
9982bb8c31 | ||
|
|
5ed94f6f85 | ||
|
|
4e13577891 | ||
|
|
d84152eb5d | ||
|
|
5d8213a987 |
+29
-11
@@ -52,10 +52,10 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: docker-compose pull
|
||||
- run: docker compose pull
|
||||
env:
|
||||
POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }}
|
||||
- run: docker-compose run postal sh -c 'bundle exec rspec'
|
||||
- run: docker compose run postal sh -c 'bundle exec rspec'
|
||||
env:
|
||||
POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }}
|
||||
|
||||
@@ -63,9 +63,14 @@ jobs:
|
||||
name: Release (branch)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
if: startsWith(github.ref, 'refs/heads/')
|
||||
if: >-
|
||||
startsWith(github.ref, 'refs/heads/') &&
|
||||
startsWith(github.ref, 'refs/heads/release-please-') != true &&
|
||||
startsWith(github.ref, 'refs/heads/dependabot/') != true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
- uses: docker/login-action@v2
|
||||
@@ -73,22 +78,35 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- id: tag
|
||||
- id: info
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/heads/}"
|
||||
if [ -z "$TAG" ]; then exit 1; fi
|
||||
if [[ $TAG == "main" ]]; then TAG="latest"; else TAG="branch-${TAG}"; fi
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
IMAGE=ghcr.io/postalserver/postal
|
||||
|
||||
REF="${GITHUB_REF#refs/heads/}"
|
||||
if [ -z "$REF" ]; then exit 1; fi
|
||||
|
||||
VER="$(git describe --tags 2>/dev/null)"
|
||||
echo "version=${VER}" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=${REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo 'tags<<EOF' >> "$GITHUB_OUTPUT"
|
||||
if [[ "$REF" == "main" ]]; then
|
||||
echo "${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "${IMAGE}:branch-${REF}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo 'EOF' >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v4
|
||||
with:
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/postalserver/postal:${{ steps.tag.outputs.tag }}
|
||||
ghcr.io/postalserver/postal:commit-${{ github.sha }}
|
||||
tags: ${{ steps.info.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
target: full
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
VERSION=${{ steps.info.outputs.version }}
|
||||
BRANCH=${{ steps.info.outputs.branch }}
|
||||
|
||||
publish-image:
|
||||
name: Publish Image
|
||||
|
||||
@@ -29,7 +29,11 @@ vendor/bundle
|
||||
|
||||
Procfile.local
|
||||
VERSION
|
||||
BRANCH
|
||||
|
||||
.rubocop-https*
|
||||
.env*
|
||||
|
||||
node_modules
|
||||
yarn.lock
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.1.1"
|
||||
".": "3.3.7"
|
||||
}
|
||||
|
||||
+5
-2
@@ -1,5 +1,5 @@
|
||||
AllCops:
|
||||
TargetRubyVersion: 3.0
|
||||
TargetRubyVersion: 3.4
|
||||
NewCops: enable
|
||||
Exclude:
|
||||
- "bin/*"
|
||||
@@ -125,6 +125,9 @@ Style/MultilineBlockChain:
|
||||
Exclude:
|
||||
- "spec/**/*.rb"
|
||||
|
||||
Style/TrailingCommaInArrayLiteral:
|
||||
EnforcedStyleForMultiline: consistent_comma
|
||||
|
||||
Metrics/AbcSize:
|
||||
Enabled: false
|
||||
|
||||
@@ -132,7 +135,7 @@ Style/FrozenStringLiteralComment:
|
||||
Enabled: true
|
||||
SafeAutoCorrect: true
|
||||
|
||||
Naming/PredicateName:
|
||||
Naming/PredicatePrefix:
|
||||
Enabled: false
|
||||
|
||||
Layout/LineLength:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.2.2
|
||||
3.4.10
|
||||
|
||||
+177
@@ -2,6 +2,183 @@
|
||||
|
||||
This file contains all the latest changes and updates to Postal.
|
||||
|
||||
## [3.3.7](https://github.com/postalserver/postal/compare/3.3.6...3.3.7) (2026-06-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **http:** prevent SSRF in outbound webhook and HTTP endpoint requests ([11c9814](https://github.com/postalserver/postal/commit/11c9814474f956619da35e8385ef7fab9f304de0))
|
||||
* **message-db:** prevent SQL injection via condition keys (GHSA-x2hq-rfpg-3xr5) ([4314a6e](https://github.com/postalserver/postal/commit/4314a6ec1e2812daa67dd20effd1db1769c1f8e8))
|
||||
* **specs:** stub IPv6 support in address guard IPv6 literal spec ([029bfe0](https://github.com/postalserver/postal/commit/029bfe098d9b8c0b5cafc49eac33e767f5748cd3))
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* **deps:** upgrade rack & rails ([0445e5c](https://github.com/postalserver/postal/commit/0445e5c509870dfe9c16366c53dee3fc02ad3904))
|
||||
* update security vulnerability reporting instructions ([8be1e27](https://github.com/postalserver/postal/commit/8be1e27fec489ab659ef5e909f705932028b1694))
|
||||
|
||||
## [3.3.6](https://github.com/postalserver/postal/compare/3.3.5...3.3.6) (2026-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **messages:** sandbox rendered email HTML as extra XSS defence ([cad2aa6](https://github.com/postalserver/postal/commit/cad2aa6808519a3ff25215f09f4966d9fa3bb372))
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* ignore node modules and yarn.lock ([b611d57](https://github.com/postalserver/postal/commit/b611d577af79b8e1e75b6d47fa04d1ba03e34eec))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **auth:** tighten return_to validation ([84f4e20](https://github.com/postalserver/postal/commit/84f4e20f05db2d11b0144f95960c956f8221e657))
|
||||
* **helpers:** escape interpolated values in select options ([9243524](https://github.com/postalserver/postal/commit/924352403553dcfcc569876ca76c219493fac9d6))
|
||||
* **tracking:** remove unused src image proxy ([dca7f90](https://github.com/postalserver/postal/commit/dca7f90b9046247c0d953567be35921167e79d87))
|
||||
|
||||
## [3.3.5](https://github.com/postalserver/postal/compare/3.3.4...3.3.5) (2026-02-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deliveries:** escape delivery details to prevent HTML injection ([11419f9](https://github.com/postalserver/postal/commit/11419f99140e13688a9613cab3ee03f8d3cbae45))
|
||||
* **health_server:** use rackup handler instead of rack handler ([7c47422](https://github.com/postalserver/postal/commit/7c47422c865e738c4d6af0fed1cca4405288341f))
|
||||
* oidc scopes are invalid when concatenated ([#3332](https://github.com/postalserver/postal/issues/3332)) ([9c5f96a](https://github.com/postalserver/postal/commit/9c5f96ae90cf06dcd5db776806865752f667bd95))
|
||||
* typo in process logging ([#3212](https://github.com/postalserver/postal/issues/3212)) ([b7e5232](https://github.com/postalserver/postal/commit/b7e5232e077b3c9b7a999dcb6676fba0ec61458e))
|
||||
* typo in the credentials page ([fd3c7cc](https://github.com/postalserver/postal/commit/fd3c7ccdf6dc4ee0a76c9523cbd735159e4b8000))
|
||||
* update url for v2 config ([#3225](https://github.com/postalserver/postal/issues/3225)) ([e00098b](https://github.com/postalserver/postal/commit/e00098b8003cf37f2708f536871b3ade377aed2d))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **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))
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* **deps:** upgrade puma, net-imap and other deps ([c03c44b](https://github.com/postalserver/postal/commit/c03c44b442a29aa9881c1e1aae60bead9776a6b6))
|
||||
* **dockerfile:** reduce container size ([86de372](https://github.com/postalserver/postal/commit/86de372382bd62bdd5d1372254f8817b0360bd56))
|
||||
* remove version from docker-compose.yml ([c78000c](https://github.com/postalserver/postal/commit/c78000ca8f2998aa04648f465060768db6467de6))
|
||||
* upgrade resolv to 0.6.2 ([d00d978](https://github.com/postalserver/postal/commit/d00d978872a96369544303d08f6a9d11cdf56b62))
|
||||
* 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))
|
||||
* upgrade uri gem to 1.0.3 ([f193b8e](https://github.com/postalserver/postal/commit/f193b8e77fc096382ab7aaa6a2c29641b4cb12df))
|
||||
|
||||
## [3.3.4](https://github.com/postalserver/postal/compare/3.3.3...3.3.4) (2024-06-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix `postal version` command ([4fa88ac](https://github.com/postalserver/postal/commit/4fa88acea0dececd0eae485506a2ad8268fbea59))
|
||||
* fix issue running message pruning task ([3a33e53](https://github.com/postalserver/postal/commit/3a33e53d843584757bb00898746aa059d7616db4))
|
||||
* raise NotImplementedError when no call method on a scheduled task ([2b0919c](https://github.com/postalserver/postal/commit/2b0919c1454eabea93db96f50ecbd8e36bb89f1f))
|
||||
|
||||
## [3.3.3](https://github.com/postalserver/postal/compare/3.3.2...3.3.3) (2024-04-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **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)
|
||||
|
||||
## [3.3.2](https://github.com/postalserver/postal/compare/3.3.1...3.3.2) (2024-03-21)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **versioning:** improve how current version and branch is determined and set ([07c6b31](https://github.com/postalserver/postal/commit/07c6b317f2b9dc04b6a8c88df1e6aa9e54597504))
|
||||
|
||||
## [3.3.1](https://github.com/postalserver/postal/compare/3.3.0...3.3.1) (2024-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **smtp-sender:** ensure relays without a host are excluded ([3a56ec8](https://github.com/postalserver/postal/commit/3a56ec8a74950e0162d98f1af5f58a67a82d6455))
|
||||
* **smtp-sender:** fixes `SMTPSender.smtp_relays` ([b3264b9](https://github.com/postalserver/postal/commit/b3264b942776e254d3c351c94c435d172a514e18))
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* **container:** add the branch name to the container ([bee5098](https://github.com/postalserver/postal/commit/bee509832edc151d97fe5bfc48c4973452873fc8))
|
||||
* **github-actions:** don't generate commit- tags ([d65bbe0](https://github.com/postalserver/postal/commit/d65bbe0579037c5df962a18134bc007f5159d7e5))
|
||||
* **github-actions:** don't run for dependabot or release-please PRs and fetch whole repo ([adaf2b0](https://github.com/postalserver/postal/commit/adaf2b07502e9ed91290873ad8465051c6fd814f))
|
||||
* **github-actions:** include a version string on branch-*/latest images ([64bc7dc](https://github.com/postalserver/postal/commit/64bc7dcf7c0a8e006ab6eb6e8b4a52ad5e7e6528))
|
||||
* **ui:** display branch in footer if present ([1823617](https://github.com/postalserver/postal/commit/18236171ebc398c157f2e61b15c7df9f91205284))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove moonrope but maintain legacy API actions ([#2889](https://github.com/postalserver/postal/issues/2889)) ([4d9654d](https://github.com/postalserver/postal/commit/4d9654dac47d59c760be96388d0421de74d3e6ac))
|
||||
|
||||
## [3.3.0](https://github.com/postalserver/postal/compare/3.2.2...3.3.0) (2024-03-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **prometheus:** add `postal_message_queue_latency` metric ([ee8d829](https://github.com/postalserver/postal/commit/ee8d829a854f91e476167869cafe35c2d37bb314))
|
||||
* **worker:** allow number of threads to be configured ([7e2accc](https://github.com/postalserver/postal/commit/7e2acccd1ebd80750a3ebdb96cb5c36b5263cc24))
|
||||
* **worker:** scale connection pool with worker threads ([ea542a0](https://github.com/postalserver/postal/commit/ea542a0694b3465b04fd3ebc439837df414deb1e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **message-dequeuer:** ability to disable batching ([4fcb9e9](https://github.com/postalserver/postal/commit/4fcb9e9a2e34be5aa4bdf13f0529f40e564b72b4))
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* **config-docs:** update docs for latest oidc defaults ([364eba6](https://github.com/postalserver/postal/commit/364eba6c5fce2f08a36489f42856ad5024a2062c))
|
||||
* **config-docs:** update proxy protocol to mention v1 ([45dd8aa](https://github.com/postalserver/postal/commit/45dd8aaac56f15481cb7bf9081401cb28dc1e707))
|
||||
|
||||
## [3.2.2](https://github.com/postalserver/postal/compare/3.2.1...3.2.2) (2024-03-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't use authentication on org & server deletion ([be45652](https://github.com/postalserver/postal/commit/be456523dd3aacb5c3eb45c9261da97ebffe603c))
|
||||
* **smtp-server:** fixes proxy protocol ([9240612](https://github.com/postalserver/postal/commit/92406129cfcf1a06499a6f5aa18c73f1d6195793))
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* allow config location message to be suppressed ([f760cdb](https://github.com/postalserver/postal/commit/f760cdb5a1d53e9c30ee495d129cbf12603a3cbd))
|
||||
* hide further config messages ([1c67f72](https://github.com/postalserver/postal/commit/1c67f72209c93404d7024ce3d15f6f54f2d707c4))
|
||||
* suppress config location on default-dkim-record ([aa76aae](https://github.com/postalserver/postal/commit/aa76aae2322af41af1bd60cfe1d69a11ac76324e))
|
||||
|
||||
|
||||
### Tests
|
||||
|
||||
* add tests for the legacy API ([3d208d6](https://github.com/postalserver/postal/commit/3d208d632f4fc8a4adbfdb2bf4b377271eae6692))
|
||||
|
||||
## [3.2.1](https://github.com/postalserver/postal/compare/3.2.0...3.2.1) (2024-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fixes `postal default-dkim-record` ([58dddeb](https://github.com/postalserver/postal/commit/58dddebeb81dc6fab945d2b10a91588eddc471c2))
|
||||
|
||||
## [3.2.0](https://github.com/postalserver/postal/compare/3.1.1...3.2.0) (2024-03-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add sha256 signatures to outgoing http requests ([#2874](https://github.com/postalserver/postal/issues/2874)) ([96d7365](https://github.com/postalserver/postal/commit/96d73653d7cb4dde1fbe74ccb3596147ef8cd9ed))
|
||||
* automatically remove queued messages with stale locks ([#2872](https://github.com/postalserver/postal/issues/2872)) ([d84152e](https://github.com/postalserver/postal/commit/d84152eb5df6f963426d6ba8d02d39b3c146c8a5))
|
||||
* openid connect support ([#2873](https://github.com/postalserver/postal/issues/2873)) ([5ed94f6](https://github.com/postalserver/postal/commit/5ed94f6f855735aa00544b2574dfb9e65d559a38))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **smtp-server:** add additional information to cram-md5 log entries ([9982bb8](https://github.com/postalserver/postal/commit/9982bb8c31ee4885d188666e2e8afdc218528df7))
|
||||
|
||||
|
||||
### Styles
|
||||
|
||||
* **rubocop:** Style/TrailingCommaInArrayLiteral ([4e13577](https://github.com/postalserver/postal/commit/4e13577891dc827244abc6bf6d9ab4ee45860556))
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* regenerate config docs ([5d8213a](https://github.com/postalserver/postal/commit/5d8213a98735f07fdf1700c7d01597654f41dbd0))
|
||||
|
||||
## [3.1.1](https://github.com/postalserver/postal/compare/3.1.0...3.1.1) (2024-03-08)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ This doc explains how to go about running Postal in development to allow you to
|
||||
|
||||
You 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.
|
||||
|
||||
You'll also need Ruby. Postal currently uses Ruby 3.2.2. Install that using whichever version manager takes your fancy - rbenv, asdf, rvm etc.
|
||||
You'll also need Ruby. Postal currently uses the Ruby version in `.ruby-version`. Install that using whichever version manager takes your fancy - rbenv, asdf, rvm etc.
|
||||
|
||||
## Clone
|
||||
|
||||
|
||||
+19
-14
@@ -1,22 +1,25 @@
|
||||
FROM ruby:3.2.2-bullseye AS base
|
||||
FROM ruby:3.4.10-slim-bookworm AS base
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
software-properties-common dirmngr apt-transport-https \
|
||||
&& (curl -sL https://deb.nodesource.com/setup_20.x | bash -) \
|
||||
&& apt-get install --no-install-recommends -y curl \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN (curl -sL https://deb.nodesource.com/setup_20.x | bash -)
|
||||
|
||||
# Install main dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
netcat \
|
||||
curl \
|
||||
libmariadb-dev \
|
||||
libcap2-bin \
|
||||
nano \
|
||||
nodejs
|
||||
build-essential \
|
||||
netcat-openbsd \
|
||||
libmariadb-dev \
|
||||
libcap2-bin \
|
||||
nano \
|
||||
libyaml-dev \
|
||||
nodejs \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/ruby
|
||||
|
||||
@@ -31,7 +34,7 @@ RUN mkdir -p /opt/postal/app /opt/postal/config
|
||||
WORKDIR /opt/postal/app
|
||||
|
||||
# Install bundler
|
||||
RUN gem install bundler -v 2.5.6 --no-doc
|
||||
RUN gem install bundler -v 2.7.2 --no-doc
|
||||
|
||||
# Install the latest and active gem dependencies and re-run
|
||||
# the appropriate commands to handle installs.
|
||||
@@ -43,8 +46,10 @@ COPY ./docker/wait-for.sh /docker-entrypoint.sh
|
||||
COPY --chown=postal . .
|
||||
|
||||
# Export the version
|
||||
ARG VERSION=unspecified
|
||||
RUN echo $VERSION > VERSION
|
||||
ARG VERSION
|
||||
ARG BRANCH
|
||||
RUN if [ "$VERSION" != "" ]; then echo $VERSION > VERSION; fi \
|
||||
&& if [ "$BRANCH" != "" ]; then echo $BRANCH > BRANCH; fi
|
||||
|
||||
# Set paths for when running in a container
|
||||
ENV POSTAL_CONFIG_FILE_PATH=/config/postal.yml
|
||||
|
||||
@@ -1,38 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
source "https://rubygems.org"
|
||||
gem "abbrev"
|
||||
gem "authie"
|
||||
gem "autoprefixer-rails"
|
||||
gem "bcrypt"
|
||||
gem "chronic"
|
||||
gem "domain_name"
|
||||
gem "dotenv"
|
||||
gem "dynamic_form"
|
||||
gem "encrypto_signo"
|
||||
gem "execjs", "~> 2.7", "< 2.8"
|
||||
gem "execjs"
|
||||
gem "gelf"
|
||||
gem "haml"
|
||||
gem "hashie"
|
||||
gem "highline", require: false
|
||||
# json 3.0 removed the `quirks_mode` option, which Active Support 7.2 still
|
||||
# passes in both its JSON encoder and decoder. Nothing else constrains json, so
|
||||
# without this pin Bundler resolves to 3.x and every `to_json` call raises
|
||||
# ArgumentError. Active Support dropped those calls in Rails 8.1, so this pin
|
||||
# can go when we move to 8.1.
|
||||
gem "json", "< 3"
|
||||
gem "jwt"
|
||||
gem "kaminari"
|
||||
gem "klogger-logger"
|
||||
gem "konfig-config", "~> 3.0"
|
||||
gem "logger"
|
||||
gem "mail"
|
||||
gem "moonrope"
|
||||
gem "mutex_m"
|
||||
gem "mysql2"
|
||||
gem "nifty-utils"
|
||||
gem "nilify_blanks"
|
||||
gem "nio4r"
|
||||
gem "ostruct"
|
||||
gem "prometheus-client"
|
||||
gem "puma"
|
||||
gem "rails", "= 7.0.8.1"
|
||||
gem "rackup"
|
||||
gem "rails", "= 7.2.3.2"
|
||||
gem "resolv"
|
||||
gem "secure_headers"
|
||||
gem "sentry-rails"
|
||||
gem "turbolinks", "~> 5"
|
||||
gem "webrick"
|
||||
|
||||
group :development, :assets do
|
||||
group :oidc do
|
||||
# These are gems which are needed for OpenID connect. They are only required by the application
|
||||
# when OIDC is enabled in the Postal configuration.
|
||||
gem "omniauth_openid_connect"
|
||||
gem "omniauth-rails_csrf_protection"
|
||||
end
|
||||
|
||||
group :development, :test, :assets do
|
||||
gem "coffee-rails", "~> 5.0"
|
||||
gem "jquery-rails"
|
||||
gem "sass-rails"
|
||||
@@ -41,12 +57,15 @@ end
|
||||
|
||||
group :development do
|
||||
gem "annotate"
|
||||
gem "database_cleaner", require: false
|
||||
gem "factory_bot_rails", require: false
|
||||
gem "rspec", require: false
|
||||
gem "rspec-rails", require: false
|
||||
gem "rubocop"
|
||||
gem "rubocop-rails"
|
||||
end
|
||||
|
||||
group :test do
|
||||
gem "database_cleaner-active_record"
|
||||
gem "factory_bot_rails"
|
||||
gem "rspec"
|
||||
gem "rspec-rails"
|
||||
gem "shoulda-matchers"
|
||||
gem "timecop"
|
||||
gem "webmock"
|
||||
|
||||
+347
-218
@@ -1,84 +1,99 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
abbrev (0.1.2)
|
||||
actioncable (7.2.3.2)
|
||||
actionpack (= 7.2.3.2)
|
||||
activesupport (= 7.2.3.2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activejob (= 7.0.8.1)
|
||||
activerecord (= 7.0.8.1)
|
||||
activestorage (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
actionview (= 7.0.8.1)
|
||||
activejob (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.8.1)
|
||||
actionview (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
rack (~> 2.0, >= 2.2.4)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.3.2)
|
||||
actionpack (= 7.2.3.2)
|
||||
activejob (= 7.2.3.2)
|
||||
activerecord (= 7.2.3.2)
|
||||
activestorage (= 7.2.3.2)
|
||||
activesupport (= 7.2.3.2)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.3.2)
|
||||
actionpack (= 7.2.3.2)
|
||||
actionview (= 7.2.3.2)
|
||||
activejob (= 7.2.3.2)
|
||||
activesupport (= 7.2.3.2)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.3.2)
|
||||
actionview (= 7.2.3.2)
|
||||
activesupport (= 7.2.3.2)
|
||||
cgi
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4, < 3.3)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activerecord (= 7.0.8.1)
|
||||
activestorage (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.3.2)
|
||||
actionpack (= 7.2.3.2)
|
||||
activerecord (= 7.2.3.2)
|
||||
activestorage (= 7.2.3.2)
|
||||
activesupport (= 7.2.3.2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
actionview (7.2.3.2)
|
||||
activesupport (= 7.2.3.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
activejob (7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
cgi
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.2.3.2)
|
||||
activesupport (= 7.2.3.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
activerecord (7.0.8.1)
|
||||
activemodel (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
activestorage (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activejob (= 7.0.8.1)
|
||||
activerecord (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
activemodel (7.2.3.2)
|
||||
activesupport (= 7.2.3.2)
|
||||
activerecord (7.2.3.2)
|
||||
activemodel (= 7.2.3.2)
|
||||
activesupport (= 7.2.3.2)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.2.3.2)
|
||||
actionpack (= 7.2.3.2)
|
||||
activejob (= 7.2.3.2)
|
||||
activerecord (= 7.2.3.2)
|
||||
activesupport (= 7.2.3.2)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.8.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
activesupport (7.2.3.2)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0)
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1, < 6)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
ast (2.4.2)
|
||||
authie (4.1.3)
|
||||
activerecord (>= 6.1, < 8.0)
|
||||
autoprefixer-rails (10.4.13.0)
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
authie (5.0.0)
|
||||
activerecord (>= 6.1, < 9.0)
|
||||
autoprefixer-rails (10.4.21.0)
|
||||
execjs (~> 2)
|
||||
bcrypt (3.1.20)
|
||||
bigdecimal (3.1.6)
|
||||
builder (3.2.4)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.3)
|
||||
bindata (3.0.0)
|
||||
builder (3.3.0)
|
||||
cgi (0.5.2)
|
||||
chronic (0.10.2)
|
||||
coffee-rails (5.0.0)
|
||||
coffee-script (>= 2.2.0)
|
||||
@@ -87,52 +102,75 @@ GEM
|
||||
coffee-script-source
|
||||
execjs
|
||||
coffee-script-source (1.12.2)
|
||||
concurrent-ruby (1.2.3)
|
||||
crack (1.0.0)
|
||||
concurrent-ruby (1.3.8)
|
||||
connection_pool (3.0.2)
|
||||
crack (1.0.1)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
database_cleaner (2.0.2)
|
||||
database_cleaner-active_record (>= 2, < 3)
|
||||
database_cleaner-active_record (2.1.0)
|
||||
crass (1.0.7)
|
||||
database_cleaner-active_record (2.2.2)
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.3.4)
|
||||
deep_merge (1.2.2)
|
||||
diff-lcs (1.5.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (3.0.2)
|
||||
dynamic_form (1.3.1)
|
||||
actionview (> 5.2.0)
|
||||
activemodel (> 5.2.0)
|
||||
encrypto_signo (1.0.0)
|
||||
erubi (1.12.0)
|
||||
execjs (2.7.0)
|
||||
factory_bot (6.4.6)
|
||||
activesupport (>= 5.0.0)
|
||||
factory_bot_rails (6.4.3)
|
||||
factory_bot (~> 6.4)
|
||||
railties (>= 5.0.0)
|
||||
database_cleaner-core (~> 2.0)
|
||||
database_cleaner-core (2.1.0)
|
||||
date (3.5.1)
|
||||
diff-lcs (1.6.2)
|
||||
domain_name (0.6.20260907)
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
erb (6.0.7)
|
||||
erubi (1.13.1)
|
||||
execjs (2.10.2)
|
||||
json (>= 2)
|
||||
factory_bot (6.6.0)
|
||||
activesupport (>= 6.1.0)
|
||||
factory_bot_rails (6.5.1)
|
||||
factory_bot (~> 6.5)
|
||||
railties (>= 6.1.0)
|
||||
faraday (2.14.3)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-net_http (3.4.4)
|
||||
net-http (~> 0.5)
|
||||
ffi (1.15.5)
|
||||
gelf (3.1.0)
|
||||
json
|
||||
globalid (1.2.1)
|
||||
globalid (1.4.0)
|
||||
activesupport (>= 6.1)
|
||||
haml (6.3.0)
|
||||
haml (7.5.1)
|
||||
prism (>= 1.1.0)
|
||||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
hashdiff (1.1.0)
|
||||
hashie (5.0.0)
|
||||
highline (2.1.0)
|
||||
i18n (1.14.1)
|
||||
hashdiff (1.2.1)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
highline (3.1.2)
|
||||
reline
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
jquery-rails (4.5.1)
|
||||
io-console (0.9.2)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jquery-rails (4.6.1)
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.7.1)
|
||||
json (2.21.2)
|
||||
json-jwt (1.17.2)
|
||||
activesupport (>= 4.2)
|
||||
aes_key_wrap
|
||||
base64
|
||||
bindata
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
jwt (3.3.0)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.2)
|
||||
@@ -145,137 +183,200 @@ GEM
|
||||
activerecord
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-core (1.2.2)
|
||||
klogger-logger (1.4.0)
|
||||
klogger-logger (1.5.0)
|
||||
concurrent-ruby (>= 1.0, < 2.0)
|
||||
json
|
||||
rouge (>= 3.30, < 5.0)
|
||||
konfig-config (3.0.0)
|
||||
hashie
|
||||
loofah (2.22.0)
|
||||
language_server-protocol (3.17.0.6)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.25.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
mail (2.9.1)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.2)
|
||||
method_source (1.0.0)
|
||||
marcel (1.2.1)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.5)
|
||||
minitest (5.22.2)
|
||||
moonrope (2.0.2)
|
||||
deep_merge (~> 1.0)
|
||||
json
|
||||
rack (>= 1.4)
|
||||
mysql2 (0.5.6)
|
||||
net-imap (0.4.10)
|
||||
minitest (5.27.0)
|
||||
mutex_m (0.3.0)
|
||||
mysql2 (0.5.7)
|
||||
bigdecimal
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.6.7)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
net-protocol (0.3.0)
|
||||
timeout
|
||||
net-smtp (0.4.0.1)
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nifty-utils (1.1.7)
|
||||
nilify_blanks (1.4.0)
|
||||
activerecord (>= 4.0.0)
|
||||
activesupport (>= 4.0.0)
|
||||
nio4r (2.7.0)
|
||||
nokogiri (1.16.2)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.2-aarch64-linux)
|
||||
nokogiri (1.19.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.2-arm64-darwin)
|
||||
nokogiri (1.19.4-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.2-x86_64-darwin)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.2-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
parallel (1.22.1)
|
||||
parser (3.2.1.1)
|
||||
omniauth (2.1.4)
|
||||
hashie (>= 3.4.6)
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-rails_csrf_protection (2.0.1)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
omniauth_openid_connect (0.8.0)
|
||||
omniauth (>= 1.9, < 3)
|
||||
openid_connect (~> 2.2)
|
||||
openid_connect (2.5.0)
|
||||
activemodel
|
||||
attr_required (>= 1.0.0)
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
json-jwt (>= 1.16)
|
||||
mail
|
||||
rack-oauth2 (~> 2.2)
|
||||
swd (~> 2.0)
|
||||
tzinfo
|
||||
validate_url
|
||||
webfinger (~> 2.0)
|
||||
ostruct (0.6.3)
|
||||
parallel (2.2.0)
|
||||
parser (3.3.12.0)
|
||||
ast (~> 2.4.1)
|
||||
prometheus-client (4.2.2)
|
||||
public_suffix (5.0.4)
|
||||
puma (6.4.2)
|
||||
racc
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
prometheus-client (5.0.0)
|
||||
base64
|
||||
public_suffix (7.0.5)
|
||||
puma (8.0.2)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.7.3)
|
||||
rack (2.2.8.1)
|
||||
rack-test (2.1.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.7)
|
||||
rack-oauth2 (2.3.0)
|
||||
activesupport
|
||||
attr_required
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
json-jwt (>= 1.11.0)
|
||||
rack (>= 2.1.0)
|
||||
rack-protection (4.2.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-session (2.1.2)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rails (7.0.8.1)
|
||||
actioncable (= 7.0.8.1)
|
||||
actionmailbox (= 7.0.8.1)
|
||||
actionmailer (= 7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
actiontext (= 7.0.8.1)
|
||||
actionview (= 7.0.8.1)
|
||||
activejob (= 7.0.8.1)
|
||||
activemodel (= 7.0.8.1)
|
||||
activerecord (= 7.0.8.1)
|
||||
activestorage (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (7.2.3.2)
|
||||
actioncable (= 7.2.3.2)
|
||||
actionmailbox (= 7.2.3.2)
|
||||
actionmailer (= 7.2.3.2)
|
||||
actionpack (= 7.2.3.2)
|
||||
actiontext (= 7.2.3.2)
|
||||
actionview (= 7.2.3.2)
|
||||
activejob (= 7.2.3.2)
|
||||
activemodel (= 7.2.3.2)
|
||||
activerecord (= 7.2.3.2)
|
||||
activestorage (= 7.2.3.2)
|
||||
activesupport (= 7.2.3.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.8.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
railties (= 7.2.3.2)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.0.8.1)
|
||||
actionpack (= 7.0.8.1)
|
||||
activesupport (= 7.0.8.1)
|
||||
method_source
|
||||
rails-html-sanitizer (1.7.1)
|
||||
loofah (~> 2.25, >= 2.25.2)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (7.2.3.2)
|
||||
actionpack (= 7.2.3.2)
|
||||
activesupport (= 7.2.3.2)
|
||||
cgi
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
zeitwerk (~> 2.5)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.1.0)
|
||||
regexp_parser (2.7.0)
|
||||
resolv (0.3.0)
|
||||
rexml (3.2.5)
|
||||
rouge (4.2.0)
|
||||
rspec (3.12.0)
|
||||
rspec-core (~> 3.12.0)
|
||||
rspec-expectations (~> 3.12.0)
|
||||
rspec-mocks (~> 3.12.0)
|
||||
rspec-core (3.12.1)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-expectations (3.12.2)
|
||||
rake (13.4.2)
|
||||
rbs (4.2.0)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.0.0)
|
||||
erb
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
regexp_parser (2.12.0)
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
resolv (0.7.2)
|
||||
rexml (3.4.4)
|
||||
rouge (4.7.0)
|
||||
rspec (3.13.2)
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
rspec-mocks (~> 3.13.0)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-mocks (3.12.4)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.8)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-rails (6.1.1)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
railties (>= 6.1)
|
||||
rspec-core (~> 3.12)
|
||||
rspec-expectations (~> 3.12)
|
||||
rspec-mocks (~> 3.12)
|
||||
rspec-support (~> 3.12)
|
||||
rspec-support (3.12.0)
|
||||
rubocop (1.48.1)
|
||||
json (~> 2.3)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.2.0.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (8.0.4)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
rspec-core (>= 3.13.0, < 5.0.0)
|
||||
rspec-expectations (>= 3.13.0, < 5.0.0)
|
||||
rspec-mocks (>= 3.13.0, < 5.0.0)
|
||||
rspec-support (>= 3.13.0, < 5.0.0)
|
||||
rspec-support (3.13.7)
|
||||
rubocop (1.91.0)
|
||||
json (>= 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (>= 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.26.0, < 2.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.27.0)
|
||||
parser (>= 3.2.1.0)
|
||||
rubocop-rails (2.18.0)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.50.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-rails (2.37.0)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop (>= 1.89.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
sass-rails (6.0.0)
|
||||
sassc-rails (~> 2.1, >= 2.1.1)
|
||||
@@ -287,82 +388,110 @@ GEM
|
||||
sprockets (> 3.0)
|
||||
sprockets-rails
|
||||
tilt
|
||||
secure_headers (6.5.0)
|
||||
sentry-rails (5.16.1)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.16.1)
|
||||
sentry-ruby (5.16.1)
|
||||
secure_headers (7.3.0)
|
||||
cgi (>= 0.1)
|
||||
securerandom (0.4.1)
|
||||
sentry-rails (7.0.0)
|
||||
railties (>= 5.2.0)
|
||||
sentry-ruby (~> 7.0.0)
|
||||
sentry-ruby (7.0.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
shoulda-matchers (6.1.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sprockets (4.2.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
logger
|
||||
shoulda-matchers (8.0.1)
|
||||
activesupport (>= 7.2)
|
||||
sprockets (4.4.1)
|
||||
concurrent-ruby (~> 1.1)
|
||||
logger
|
||||
rack (>= 2.2.4, < 4)
|
||||
sprockets-rails (3.4.2)
|
||||
actionpack (>= 5.2)
|
||||
activesupport (>= 5.2)
|
||||
sprockets-rails (3.5.2)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
sprockets (>= 3.0.0)
|
||||
temple (0.10.3)
|
||||
thor (1.3.0)
|
||||
tilt (2.3.0)
|
||||
timecop (0.9.8)
|
||||
timeout (0.4.1)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
attr_required (>= 0.0.5)
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
temple (0.10.7)
|
||||
thor (1.5.0)
|
||||
tilt (2.9.0)
|
||||
timecop (0.9.11)
|
||||
timeout (0.6.1)
|
||||
tsort (0.2.0)
|
||||
turbolinks (5.2.1)
|
||||
turbolinks-source (~> 5.2)
|
||||
turbolinks-source (5.2.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
uglifier (4.2.0)
|
||||
uglifier (4.2.1)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
unicode-display_width (2.4.2)
|
||||
webmock (3.20.0)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
validate_url (1.0.15)
|
||||
activemodel (>= 3.0.0)
|
||||
public_suffix
|
||||
webfinger (2.1.3)
|
||||
activesupport
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
webmock (3.26.4)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.8.1)
|
||||
websocket-driver (0.7.6)
|
||||
webrick (1.9.2)
|
||||
websocket-driver (0.8.2)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
zeitwerk (2.6.13)
|
||||
zeitwerk (2.8.3)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
arm64-darwin
|
||||
ruby
|
||||
x86_64-darwin
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
abbrev
|
||||
annotate
|
||||
authie
|
||||
autoprefixer-rails
|
||||
bcrypt
|
||||
chronic
|
||||
coffee-rails (~> 5.0)
|
||||
database_cleaner
|
||||
database_cleaner-active_record
|
||||
domain_name
|
||||
dotenv
|
||||
dynamic_form
|
||||
encrypto_signo
|
||||
execjs (~> 2.7, < 2.8)
|
||||
execjs
|
||||
factory_bot_rails
|
||||
gelf
|
||||
haml
|
||||
hashie
|
||||
highline
|
||||
jquery-rails
|
||||
json (< 3)
|
||||
jwt
|
||||
kaminari
|
||||
klogger-logger
|
||||
konfig-config (~> 3.0)
|
||||
logger
|
||||
mail
|
||||
moonrope
|
||||
mutex_m
|
||||
mysql2
|
||||
nifty-utils
|
||||
nilify_blanks
|
||||
nio4r
|
||||
omniauth-rails_csrf_protection
|
||||
omniauth_openid_connect
|
||||
ostruct
|
||||
prometheus-client
|
||||
puma
|
||||
rails (= 7.0.8.1)
|
||||
rackup
|
||||
rails (= 7.2.3.2)
|
||||
resolv
|
||||
rspec
|
||||
rspec-rails
|
||||
@@ -379,4 +508,4 @@ DEPENDENCIES
|
||||
webrick
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.6
|
||||
2.7.2
|
||||
|
||||
+2
-2
@@ -11,5 +11,5 @@ We only support updates to the 3.x versions of Postal.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a vulnerability in Postal, please do not post an issue on GitHub. Instead you should send an
|
||||
e-mail to security@postalserver.io with details. We will get back to you directly.
|
||||
If you discover a vulnerability in Postal, please do not post an issue on GitHub. Please,
|
||||
instead, [create a new security advisory through GitHub](https://github.com/postalserver/postal/security/advisories/new).
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
authenticator :server do
|
||||
friendly_name "Server Authenticator"
|
||||
header "X-Server-API-Key", "The API token for a server that you wish to authenticate with.", example: "f29a45f0d4e1744ebaee"
|
||||
error "InvalidServerAPIKey", "The API token provided in X-Server-API-Key was not valid.", attributes: { token: "The token that was looked up" }
|
||||
error "ServerSuspended", "The mail server has been suspended"
|
||||
lookup do
|
||||
if key = request.headers["X-Server-API-Key"]
|
||||
if credential = Credential.where(type: "API", key: key).first
|
||||
if credential.server.suspended?
|
||||
error "ServerSuspended"
|
||||
else
|
||||
credential.use
|
||||
credential
|
||||
end
|
||||
else
|
||||
error "InvalidServerAPIKey", token: key
|
||||
end
|
||||
end
|
||||
end
|
||||
rule :default, "AccessDenied", "Must be authenticated as a server." do
|
||||
identity.is_a?(Credential)
|
||||
end
|
||||
end
|
||||
|
||||
authenticator :anonymous do
|
||||
rule :default, "MustNotBeAuthenticated", "Must not be authenticated." do
|
||||
identity.nil?
|
||||
end
|
||||
end
|
||||
@@ -1,41 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
controller :messages do
|
||||
friendly_name "Messages API"
|
||||
description "This API allows you to access message details"
|
||||
authenticator :server
|
||||
|
||||
action :message do
|
||||
title "Return message details"
|
||||
description "Returns all details about a message"
|
||||
param :id, "The ID of the message", type: Integer, required: true
|
||||
returns Hash, structure: :message, structure_opts: { paramable: { expansions: false } }
|
||||
error "MessageNotFound", "No message found matching provided ID", attributes: { id: "The ID of the message" }
|
||||
action do
|
||||
begin
|
||||
message = identity.server.message(params.id)
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
error "MessageNotFound", id: params.id
|
||||
end
|
||||
structure :message, message, return: true
|
||||
end
|
||||
end
|
||||
|
||||
action :deliveries do
|
||||
title "Return deliveries for a message"
|
||||
description "Returns an array of deliveries which have been attempted for this message"
|
||||
param :id, "The ID of the message", type: Integer, required: true
|
||||
returns Array, structure: :delivery, structure_opts: { full: true }
|
||||
error "MessageNotFound", "No message found matching provided ID", attributes: { id: "The ID of the message" }
|
||||
action do
|
||||
begin
|
||||
message = identity.server.message(params.id)
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
error "MessageNotFound", id: params.id
|
||||
end
|
||||
message.deliveries.map do |d|
|
||||
structure :delivery, d
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,112 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
controller :send do
|
||||
friendly_name "Send API"
|
||||
description "This API allows you to send messages"
|
||||
authenticator :server
|
||||
|
||||
action :message do
|
||||
title "Send a message"
|
||||
description "This action allows you to send a message by providing the appropriate options"
|
||||
# Acceptable Parameters
|
||||
param :to, "The e-mail addresses of the recipients (max 50)", type: Array
|
||||
param :cc, "The e-mail addresses of any CC contacts (max 50)", type: Array
|
||||
param :bcc, "The e-mail addresses of any BCC contacts (max 50)", type: Array
|
||||
param :from, "The e-mail address for the From header", type: String
|
||||
param :sender, "The e-mail address for the Sender header", type: String
|
||||
param :subject, "The subject of the e-mail", type: String
|
||||
param :tag, "The tag of the e-mail", type: String
|
||||
param :reply_to, "Set the reply-to address for the mail", type: String
|
||||
param :plain_body, "The plain text body of the e-mail", type: String
|
||||
param :html_body, "The HTML body of the e-mail", type: String
|
||||
param :attachments, "An array of attachments for this e-mail", type: Array
|
||||
param :headers, "A hash of additional headers", type: Hash
|
||||
param :bounce, "Is this message a bounce?", type: :boolean
|
||||
# Errors
|
||||
error "ValidationError", "The provided data was not sufficient to send an email", attributes: { errors: "A hash of error details" }
|
||||
error "NoRecipients", "There are no recipients defined to receive this message"
|
||||
error "NoContent", "There is no content defined for this e-mail"
|
||||
error "TooManyToAddresses", "The maximum number of To addresses has been reached (maximum 50)"
|
||||
error "TooManyCCAddresses", "The maximum number of CC addresses has been reached (maximum 50)"
|
||||
error "TooManyBCCAddresses", "The maximum number of BCC addresses has been reached (maximum 50)"
|
||||
error "FromAddressMissing", "The From address is missing and is required"
|
||||
error "UnauthenticatedFromAddress", "The From address is not authorised to send mail from this server"
|
||||
error "AttachmentMissingName", "An attachment is missing a name"
|
||||
error "AttachmentMissingData", "An attachment is missing data"
|
||||
# Return
|
||||
returns Hash
|
||||
# Action
|
||||
action do
|
||||
attributes = {}
|
||||
attributes[:to] = params.to
|
||||
attributes[:cc] = params.cc
|
||||
attributes[:bcc] = params.bcc
|
||||
attributes[:from] = params.from
|
||||
attributes[:sender] = params.sender
|
||||
attributes[:subject] = params.subject
|
||||
attributes[:reply_to] = params.reply_to
|
||||
attributes[:plain_body] = params.plain_body
|
||||
attributes[:html_body] = params.html_body
|
||||
attributes[:bounce] = params.bounce ? true : false
|
||||
attributes[:tag] = params.tag
|
||||
attributes[:custom_headers] = params.headers
|
||||
attributes[:attachments] = []
|
||||
(params.attachments || []).each do |attachment|
|
||||
next unless attachment.is_a?(Hash)
|
||||
|
||||
attributes[:attachments] << { name: attachment["name"], content_type: attachment["content_type"], data: attachment["data"], base64: true }
|
||||
end
|
||||
message = OutgoingMessagePrototype.new(identity.server, request.ip, "api", attributes)
|
||||
message.credential = identity
|
||||
if message.valid?
|
||||
result = message.create_messages
|
||||
{ message_id: message.message_id, messages: result }
|
||||
else
|
||||
error message.errors.first
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
action :raw do
|
||||
title "Send a raw RFC2822 message"
|
||||
description "This action allows you to send us a raw RFC2822 formatted message along with the recipients that it should be sent to. This is similar to sending a message through our SMTP service."
|
||||
param :mail_from, "The address that should be logged as sending the message", type: String, required: true
|
||||
param :rcpt_to, "The addresses this message should be sent to", type: Array, required: true
|
||||
param :data, "A base64 encoded RFC2822 message to send", type: String, required: true
|
||||
param :bounce, "Is this message a bounce?", type: :boolean
|
||||
returns Hash
|
||||
error "UnauthenticatedFromAddress", "The From address is not authorised to send mail from this server"
|
||||
action do
|
||||
# Decode the raw message
|
||||
raw_message = Base64.decode64(params.data)
|
||||
|
||||
# Parse through mail to get the from/sender headers
|
||||
mail = Mail.new(raw_message.split("\r\n\r\n", 2).first)
|
||||
from_headers = { "from" => mail.from, "sender" => mail.sender }
|
||||
authenticated_domain = identity.server.find_authenticated_domain_from_headers(from_headers)
|
||||
|
||||
# If we're not authenticated, don't continue
|
||||
if authenticated_domain.nil?
|
||||
error "UnauthenticatedFromAddress"
|
||||
end
|
||||
|
||||
# Store the result ready to return
|
||||
result = { message_id: nil, messages: {} }
|
||||
params.rcpt_to.uniq.each do |rcpt_to|
|
||||
message = identity.server.message_db.new_message
|
||||
message.rcpt_to = rcpt_to
|
||||
message.mail_from = params.mail_from
|
||||
message.raw_message = raw_message
|
||||
message.received_with_ssl = true
|
||||
message.scope = "outgoing"
|
||||
message.domain_id = authenticated_domain.id
|
||||
message.credential_id = identity.id
|
||||
message.bounce = params.bounce
|
||||
message.save
|
||||
result[:message_id] = message.message_id if result[:message_id].nil?
|
||||
result[:messages][rcpt_to] = { id: message.id, token: message.token }
|
||||
end
|
||||
result
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,12 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
structure :delivery do
|
||||
basic :id
|
||||
basic :status
|
||||
basic :details
|
||||
basic :output, value: proc { o.output&.strip }
|
||||
basic :sent_with_ssl, value: proc { o.sent_with_ssl }
|
||||
basic :log_id
|
||||
basic :time, value: proc { o.time&.to_f }
|
||||
basic :timestamp, value: proc { o.timestamp.to_f }
|
||||
end
|
||||
@@ -1,68 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
structure :message do
|
||||
basic :id
|
||||
basic :token
|
||||
|
||||
expansion(:status) do
|
||||
{
|
||||
status: o.status,
|
||||
last_delivery_attempt: o.last_delivery_attempt&.to_f,
|
||||
held: o.held,
|
||||
hold_expiry: o.hold_expiry&.to_f
|
||||
}
|
||||
end
|
||||
|
||||
expansion(:details) do
|
||||
{
|
||||
rcpt_to: o.rcpt_to,
|
||||
mail_from: o.mail_from,
|
||||
subject: o.subject,
|
||||
message_id: o.message_id,
|
||||
timestamp: o.timestamp.to_f,
|
||||
direction: o.scope,
|
||||
size: o.size,
|
||||
bounce: o.bounce,
|
||||
bounce_for_id: o.bounce_for_id,
|
||||
tag: o.tag,
|
||||
received_with_ssl: o.received_with_ssl
|
||||
}
|
||||
end
|
||||
|
||||
expansion(:inspection) do
|
||||
{
|
||||
inspected: o.inspected,
|
||||
spam: o.spam,
|
||||
spam_score: o.spam_score.to_f,
|
||||
threat: o.threat,
|
||||
threat_details: o.threat_details
|
||||
}
|
||||
end
|
||||
|
||||
expansion(:plain_body) { o.plain_body }
|
||||
|
||||
expansion(:html_body) { o.html_body }
|
||||
|
||||
expansion(:attachments) do
|
||||
o.attachments.map do |attachment|
|
||||
{
|
||||
filename: attachment.filename.to_s,
|
||||
content_type: attachment.mime_type,
|
||||
data: Base64.encode64(attachment.body.to_s),
|
||||
size: attachment.body.to_s.bytesize,
|
||||
hash: Digest::SHA1.hexdigest(attachment.body.to_s)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
expansion(:headers) { o.headers }
|
||||
|
||||
expansion(:raw_message) { Base64.encode64(o.raw_message) }
|
||||
|
||||
expansion(:activity_entries) do
|
||||
{
|
||||
loads: o.loads,
|
||||
clicks: o.clicks
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -9,14 +9,6 @@
|
||||
line-height:1.5;
|
||||
}
|
||||
|
||||
.errorExplanation h2 {
|
||||
display:none;
|
||||
}
|
||||
|
||||
.errorExplanation p {
|
||||
display:none;
|
||||
}
|
||||
|
||||
.errorExplanation ul li {
|
||||
list-style:disc;
|
||||
margin-left:20px;
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
.loginForm {
|
||||
|
||||
}
|
||||
.loginForm {}
|
||||
|
||||
|
||||
.loginForm__input {
|
||||
margin-bottom:15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.loginForm__submit {
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
align-items:center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
.loginForm__links {
|
||||
font-size:12px;
|
||||
color:#999;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-decoration: underline;
|
||||
line-height:1.7;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.loginForm__divider {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 25px;
|
||||
border-top: 1px solid #e4e8ef;
|
||||
}
|
||||
|
||||
.loginForm__localTitle {
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
@@ -1,74 +1,67 @@
|
||||
.userList {
|
||||
border-radius:4px;
|
||||
color:$darkBlue;
|
||||
overflow:hidden;
|
||||
box-shadow:0 0 10px rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
color: $darkBlue;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.userList__item {
|
||||
display:block;
|
||||
background:#fff;
|
||||
padding:15px;
|
||||
display:flex;
|
||||
display: block;
|
||||
background: #fff;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.userList__item:nth-child(even) {
|
||||
background:none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.userList__item + .userList__item {
|
||||
border-top:1px solid lighten(#ccd4e0, 10%);
|
||||
}
|
||||
|
||||
.userList__avatar {
|
||||
width:50px;
|
||||
height:50px;
|
||||
border-radius:50%;
|
||||
background:#fff;
|
||||
border:2px solid #efefef;
|
||||
padding:3px;
|
||||
flex: 0 0 auto;
|
||||
.userList__item+.userList__item {
|
||||
border-top: 1px solid lighten(#ccd4e0, 10%);
|
||||
}
|
||||
|
||||
.userList__details {
|
||||
flex: 1 1 auto;
|
||||
margin:0 25px;
|
||||
margin: 0 0;
|
||||
}
|
||||
|
||||
|
||||
.userList__actions {
|
||||
flex: 0 0 auto;
|
||||
width:180px;
|
||||
font-size:12px;
|
||||
line-height:1.5;
|
||||
color:#999;
|
||||
text-decoration: underline;
|
||||
width: 120px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #999;
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.userList__name {
|
||||
font-weight:600;
|
||||
font-size:16px;
|
||||
margin-bottom:3px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.userList__owner {
|
||||
vertical-align:2px;
|
||||
margin-left:5px;
|
||||
background-color:$orange;
|
||||
vertical-align: 2px;
|
||||
margin-left: 5px;
|
||||
background-color: $orange;
|
||||
}
|
||||
|
||||
.userList__pending {
|
||||
vertical-align:2px;
|
||||
margin-left:5px;
|
||||
background-color:#ccc;
|
||||
vertical-align: 2px;
|
||||
margin-left: 5px;
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
.userList__admin {
|
||||
vertical-align:2px;
|
||||
margin-left:5px;
|
||||
background-color:$blue;
|
||||
.userList__tag {
|
||||
vertical-align: 2px;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.userList__revoke {
|
||||
color:$red;
|
||||
color: $red;
|
||||
}
|
||||
|
||||
@@ -1,98 +1,117 @@
|
||||
.button {
|
||||
display:inline-block;
|
||||
font:inherit;
|
||||
border-radius:4px;
|
||||
appearance:none;
|
||||
background:$blue;
|
||||
color:#fff;
|
||||
font-size:14px !important;
|
||||
margin:0;
|
||||
vertical-align:top;
|
||||
padding:6px 15px;
|
||||
border:2px solid transparent;
|
||||
border-bottom:2px solid darken($blue, 20%);
|
||||
display: inline-block;
|
||||
font: inherit;
|
||||
border-radius: 4px;
|
||||
appearance: none;
|
||||
background: $blue;
|
||||
color: #fff;
|
||||
font-size: 14px !important;
|
||||
margin: 0;
|
||||
vertical-align: top;
|
||||
padding: 6px 15px;
|
||||
border: 2px solid transparent;
|
||||
border-bottom: 2px solid darken($blue, 20%);
|
||||
|
||||
&:active {
|
||||
background-color:darken($blue, 15%);
|
||||
background-color: darken($blue, 15%);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color:darken($blue, 15%);
|
||||
background-color:lighten($blue, 5%);
|
||||
border-color: darken($blue, 15%);
|
||||
background-color: lighten($blue, 5%);
|
||||
}
|
||||
|
||||
&.is-spinning {
|
||||
color:transparent;
|
||||
background-repeat:no-repeat;
|
||||
color: transparent;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size:25px;
|
||||
background-image:image-url('button-spinner.gif');
|
||||
background-size: 25px;
|
||||
background-image: image-url('button-spinner.gif');
|
||||
}
|
||||
}
|
||||
|
||||
.button--small {
|
||||
font-size:12px !important;
|
||||
padding:3px 10px;
|
||||
border-width:1px;
|
||||
font-size: 12px !important;
|
||||
padding: 3px 10px;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.button--positive {
|
||||
background-color:$green;
|
||||
border-bottom-color:darken($green, 15%);
|
||||
background-color: $green;
|
||||
border-bottom-color: darken($green, 15%);
|
||||
|
||||
&:active {
|
||||
background-color:darken($green, 15%);
|
||||
background-color: darken($green, 15%);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color:darken($green, 15%);
|
||||
background-color:lighten($green, 5%);
|
||||
border-color: darken($green, 15%);
|
||||
background-color: lighten($green, 5%);
|
||||
}
|
||||
|
||||
&.is-spinning {
|
||||
background-image:image-url('button-spinner-positive.gif');
|
||||
background-image: image-url('button-spinner-positive.gif');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
.button--neutral {
|
||||
background-color:#ccc;
|
||||
border-bottom-color:darken(#ccc, 15%);
|
||||
background-color: #ccc;
|
||||
border-bottom-color: darken(#ccc, 15%);
|
||||
|
||||
&:active {
|
||||
background-color:darken(#ccc, 15%);
|
||||
background-color: darken(#ccc, 15%);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color:darken(#ccc, 15%);
|
||||
background-color:lighten(#ccc, 5%);
|
||||
border-color: darken(#ccc, 15%);
|
||||
background-color: lighten(#ccc, 5%);
|
||||
}
|
||||
|
||||
&.is-spinning {
|
||||
background-image:image-url('button-spinner-neutral.gif');
|
||||
background-image: image-url('button-spinner-neutral.gif');
|
||||
}
|
||||
}
|
||||
|
||||
.button--danger {
|
||||
background-color:$red;
|
||||
border-bottom-color:darken($red, 15%);
|
||||
background-color: $red;
|
||||
border-bottom-color: darken($red, 15%);
|
||||
|
||||
&:active {
|
||||
background-color:darken($red, 15%);
|
||||
background-color: darken($red, 15%);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color:darken($red, 15%);
|
||||
background-color:lighten($red, 5%);
|
||||
border-color: darken($red, 15%);
|
||||
background-color: lighten($red, 5%);
|
||||
}
|
||||
|
||||
&.is-spinning {
|
||||
background-image:image-url('button-spinner-danger.gif');
|
||||
background-image: image-url('button-spinner-danger.gif');
|
||||
}
|
||||
}
|
||||
|
||||
.button--dark {
|
||||
background-color:$darkBlue;
|
||||
border-bottom-color:darken($darkBlue, 15%);
|
||||
background-color: $darkBlue;
|
||||
border-bottom-color: darken($darkBlue, 15%);
|
||||
|
||||
&:active {
|
||||
background-color:darken($darkBlue, 15%);
|
||||
background-color: darken($darkBlue, 15%);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color:darken($darkBlue, 15%);
|
||||
background-color:lighten($darkBlue, 5%);
|
||||
border-color: darken($darkBlue, 15%);
|
||||
background-color: lighten($darkBlue, 5%);
|
||||
}
|
||||
|
||||
&.is-spinning {
|
||||
background-image:image-url('button-spinner-dark.gif');
|
||||
background-image: image-url('button-spinner-dark.gif');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.button--full {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1,136 +1,137 @@
|
||||
.label {
|
||||
display:inline-block;
|
||||
background:#000;
|
||||
color:#fff;
|
||||
font-size:9px;
|
||||
display: inline-block;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
border-radius:40px;
|
||||
padding:2px 6px;
|
||||
line-height:0.9;
|
||||
border-radius: 40px;
|
||||
padding: 2px 6px;
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.label--green {
|
||||
background-color:$green;
|
||||
background-color: $green;
|
||||
}
|
||||
|
||||
.label--red {
|
||||
background-color:$red;
|
||||
background-color: $red;
|
||||
}
|
||||
|
||||
.label--orange {
|
||||
background-color:$orange;
|
||||
background-color: $orange;
|
||||
}
|
||||
|
||||
.label--blue {
|
||||
background-color:$blue;
|
||||
background-color: $blue;
|
||||
}
|
||||
|
||||
.label--grey {
|
||||
background-color:#999;
|
||||
background-color: #999;
|
||||
}
|
||||
|
||||
.label--turquoise {
|
||||
background-color:$blue;
|
||||
background-color: $blue;
|
||||
}
|
||||
|
||||
.label--purple {
|
||||
background-color:$purple;
|
||||
background-color: $purple;
|
||||
}
|
||||
|
||||
.label--large {
|
||||
font-size:11px;
|
||||
padding:4px 10px;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.label--serverStatus-live {
|
||||
background-color:$green;
|
||||
background-color: $green;
|
||||
}
|
||||
|
||||
.label--serverStatus-development {
|
||||
background-color:#636363;
|
||||
background-color: #636363;
|
||||
}
|
||||
|
||||
.label--serverStatus-suspended {
|
||||
background-color:$red;
|
||||
background-color: $red;
|
||||
}
|
||||
|
||||
.label--messageStatus-pending {
|
||||
background-color:$subBlue;
|
||||
background-color: $subBlue;
|
||||
}
|
||||
|
||||
.label--messageStatus-held {
|
||||
background-color:#aaa;
|
||||
background-color: #aaa;
|
||||
}
|
||||
|
||||
.label--messageStatus-processed {
|
||||
background-color:$green;
|
||||
background-color: $green;
|
||||
}
|
||||
|
||||
.label--messageStatus-sent {
|
||||
background-color:$green;
|
||||
background-color: $green;
|
||||
}
|
||||
|
||||
.label--messageStatus-hard_fail {
|
||||
background-color:$red;
|
||||
background-color: $red;
|
||||
}
|
||||
|
||||
.label--messageStatus-soft_fail {
|
||||
background-color:$orange;
|
||||
background-color: $orange;
|
||||
}
|
||||
|
||||
.label--messageStatus-bounced {
|
||||
background-color:$red;
|
||||
background-color: $red;
|
||||
}
|
||||
|
||||
.label--messageStatus-hold_cancelled {
|
||||
background-color:#ccc;
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
|
||||
.label--credentialType-api {
|
||||
background-color:$blue;
|
||||
background-color: $blue;
|
||||
}
|
||||
|
||||
.label--credentialType-smtp {
|
||||
background-color:$turquoise;
|
||||
background-color: $turquoise;
|
||||
}
|
||||
|
||||
.label--credentialType-smtp_ip {
|
||||
background-color:$orange;
|
||||
background-color: $orange;
|
||||
}
|
||||
|
||||
.label--spamStatus-not_checked {
|
||||
background:#aaa;
|
||||
background: #aaa;
|
||||
}
|
||||
|
||||
.label--spamStatus-spam {
|
||||
background:$orange;
|
||||
background: $orange;
|
||||
}
|
||||
|
||||
.label--spamStatus-not_spam {
|
||||
background:$turquoise;
|
||||
background: $turquoise;
|
||||
}
|
||||
|
||||
.label--http-status-2 {
|
||||
background-color:$green;
|
||||
background-color: $green;
|
||||
}
|
||||
|
||||
.label--http-status-3 {
|
||||
background-color:$orange;
|
||||
background-color: $orange;
|
||||
}
|
||||
|
||||
.label--http-status-4,
|
||||
.label--http-status-5 {
|
||||
background-color:$red;
|
||||
background-color: $red;
|
||||
}
|
||||
|
||||
.domainList__ssl {
|
||||
color:$green;
|
||||
color: $green;
|
||||
|
||||
&:hover {
|
||||
text-decoration:underline;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.domainList__ssl--disabled {
|
||||
color:#999;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
@@ -62,10 +62,13 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def url_with_return_to(url)
|
||||
if params[:return_to].blank? || !params[:return_to].starts_with?("/")
|
||||
return_to = params[:return_to]
|
||||
if return_to.blank? ||
|
||||
!return_to.start_with?("/") ||
|
||||
return_to.start_with?("//", "/\\")
|
||||
url_for(url)
|
||||
else
|
||||
params[:return_to]
|
||||
return_to
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module LegacyAPI
|
||||
# The Legacy API is the Postal v1 API which existed from the start with main
|
||||
# aim of allowing e-mails to sent over HTTP rather than SMTP. The API itself
|
||||
# did not feature much functionality. This API was implemented using Moonrope
|
||||
# which was a self documenting API tool, however, is now no longer maintained.
|
||||
# In light of that, these controllers now implement the same functionality as
|
||||
# the original Moonrope API without the actual requirement to use any of the
|
||||
# Moonrope components.
|
||||
#
|
||||
# Important things to note about the API:
|
||||
#
|
||||
# * Moonrope allow params to be provided as JSON in the body of the request
|
||||
# along with the application/json content type. It also allowed for params
|
||||
# to be sent in the 'params' parameter when using the
|
||||
# application/x-www-form-urlencoded content type. Both methods are supported.
|
||||
#
|
||||
# * Authentication is performed using a X-Server-API-Key variable.
|
||||
#
|
||||
# * The method used to make the request is not important. Most clients use POST
|
||||
# but other methods should be supported. The routing for this legacvy
|
||||
# API supports GET, POST, PUT and PATCH.
|
||||
#
|
||||
# * The status code for responses will always be 200 OK. The actual status of
|
||||
# a request is determined by the value of the 'status' attribute in the
|
||||
# returned JSON.
|
||||
class BaseController < ActionController::Base
|
||||
|
||||
skip_before_action :set_browser_id
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
before_action :start_timer
|
||||
before_action :authenticate_as_server
|
||||
|
||||
private
|
||||
|
||||
# The Moonrope API spec allows for parameters to be provided in the body
|
||||
# along with the application/json content type or they can be provided,
|
||||
# as JSON, in the 'params' parameter when used with the
|
||||
# application/x-www-form-urlencoded content type. This legacy API needs
|
||||
# support both options for maximum compatibility.
|
||||
#
|
||||
# @return [Hash]
|
||||
def api_params
|
||||
if request.headers["content-type"] =~ /\Aapplication\/json/
|
||||
return params.to_unsafe_hash
|
||||
end
|
||||
|
||||
if params["params"].present?
|
||||
return JSON.parse(params["params"])
|
||||
end
|
||||
|
||||
{}
|
||||
end
|
||||
|
||||
# The API returns a length of time to complete a request. We'll start
|
||||
# a timer when the request starts and then use this method to calculate
|
||||
# the time taken to complete the request.
|
||||
#
|
||||
# @return [void]
|
||||
def start_timer
|
||||
@start_time = Time.now.to_f
|
||||
end
|
||||
|
||||
# The only method available to authenticate to the legacy API is using a
|
||||
# credential from the server itself. This method will attempt to find
|
||||
# that credential from the X-Server-API-Key header and will set the
|
||||
# current_credential instance variable if a token is valid. Otherwise it
|
||||
# will render an error to halt execution.
|
||||
#
|
||||
# @return [void]
|
||||
def authenticate_as_server
|
||||
key = request.headers["X-Server-API-Key"]
|
||||
if key.blank?
|
||||
render_error "AccessDenied",
|
||||
message: "Must be authenticated as a server."
|
||||
return
|
||||
end
|
||||
|
||||
credential = Credential.where(type: "API", key: key).first
|
||||
if credential.nil?
|
||||
render_error "InvalidServerAPIKey",
|
||||
message: "The API token provided in X-Server-API-Key was not valid.",
|
||||
token: key
|
||||
return
|
||||
end
|
||||
|
||||
if credential.server.suspended?
|
||||
render_error "ServerSuspended"
|
||||
return
|
||||
end
|
||||
|
||||
credential.use
|
||||
@current_credential = credential
|
||||
end
|
||||
|
||||
# Render a successful response to the client
|
||||
#
|
||||
# @param [Hash] data
|
||||
# @return [void]
|
||||
def render_success(data)
|
||||
render json: { status: "success",
|
||||
time: (Time.now.to_f - @start_time).round(3),
|
||||
flags: {},
|
||||
data: data }
|
||||
end
|
||||
|
||||
# Render an error response to the client
|
||||
#
|
||||
# @param [String] code
|
||||
# @param [Hash] data
|
||||
# @return [void]
|
||||
def render_error(code, data = {})
|
||||
render json: { status: "error",
|
||||
time: (Time.now.to_f - @start_time).round(3),
|
||||
flags: {},
|
||||
data: data.merge(code: code) }
|
||||
end
|
||||
|
||||
# Render a parameter error response to the client
|
||||
#
|
||||
# @param [String] message
|
||||
# @return [void]
|
||||
def render_parameter_error(message)
|
||||
render json: { status: "parameter-error",
|
||||
time: (Time.now.to_f - @start_time).round(3),
|
||||
flags: {},
|
||||
data: { message: message } }
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,166 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module LegacyAPI
|
||||
class MessagesController < BaseController
|
||||
|
||||
# Returns details about a given message
|
||||
#
|
||||
# URL: /api/v1/messages/message
|
||||
#
|
||||
# Parameters: id => REQ: The ID of the message
|
||||
# _expansions => An array of types of details t
|
||||
# to return
|
||||
#
|
||||
# Response: A hash containing message information
|
||||
# OR an error if the message does not exist.
|
||||
#
|
||||
def message
|
||||
message = find_message
|
||||
return if performed?
|
||||
|
||||
message_hash = { id: message.id, token: message.token }
|
||||
expansions = api_params["_expansions"]
|
||||
|
||||
if expansions == true || (expansions.is_a?(Array) && expansions.include?("status"))
|
||||
message_hash[:status] = {
|
||||
status: message.status,
|
||||
last_delivery_attempt: message.last_delivery_attempt&.to_f,
|
||||
held: message.held,
|
||||
hold_expiry: message.hold_expiry&.to_f
|
||||
}
|
||||
end
|
||||
|
||||
if expansions == true || (expansions.is_a?(Array) && expansions.include?("details"))
|
||||
message_hash[:details] = {
|
||||
rcpt_to: message.rcpt_to,
|
||||
mail_from: message.mail_from,
|
||||
subject: message.subject,
|
||||
message_id: message.message_id,
|
||||
timestamp: message.timestamp.to_f,
|
||||
direction: message.scope,
|
||||
size: message.size,
|
||||
bounce: message.bounce,
|
||||
bounce_for_id: message.bounce_for_id,
|
||||
tag: message.tag,
|
||||
received_with_ssl: message.received_with_ssl
|
||||
}
|
||||
end
|
||||
|
||||
if expansions == true || (expansions.is_a?(Array) && expansions.include?("inspection"))
|
||||
message_hash[:inspection] = {
|
||||
inspected: message.inspected,
|
||||
spam: message.spam,
|
||||
spam_score: message.spam_score.to_f,
|
||||
threat: message.threat,
|
||||
threat_details: message.threat_details
|
||||
}
|
||||
end
|
||||
|
||||
if expansions == true || (expansions.is_a?(Array) && expansions.include?("plain_body"))
|
||||
message_hash[:plain_body] = message.plain_body
|
||||
end
|
||||
|
||||
if expansions == true || (expansions.is_a?(Array) && expansions.include?("html_body"))
|
||||
message_hash[:html_body] = message.html_body
|
||||
end
|
||||
|
||||
if expansions == true || (expansions.is_a?(Array) && expansions.include?("attachments"))
|
||||
message_hash[:attachments] = message.attachments.map do |attachment|
|
||||
{
|
||||
filename: attachment.filename.to_s,
|
||||
content_type: attachment.mime_type,
|
||||
data: Base64.encode64(attachment.body.to_s),
|
||||
size: attachment.body.to_s.bytesize,
|
||||
hash: Digest::SHA1.hexdigest(attachment.body.to_s)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
if expansions == true || (expansions.is_a?(Array) && expansions.include?("headers"))
|
||||
message_hash[:headers] = message.headers
|
||||
end
|
||||
|
||||
if expansions == true || (expansions.is_a?(Array) && expansions.include?("raw_message"))
|
||||
message_hash[:raw_message] = Base64.encode64(message.raw_message)
|
||||
end
|
||||
|
||||
if expansions == true || (expansions.is_a?(Array) && expansions.include?("activity_entries"))
|
||||
message_hash[:activity_entries] = {
|
||||
loads: message.loads,
|
||||
clicks: message.clicks
|
||||
}
|
||||
end
|
||||
|
||||
render_success message_hash
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
render_error "MessageNotFound",
|
||||
message: "No message found matching provided ID",
|
||||
id: api_params["id"]
|
||||
end
|
||||
|
||||
# Returns all the deliveries for a given message
|
||||
#
|
||||
# URL: /api/v1/messages/deliveries
|
||||
#
|
||||
# Parameters: id => REQ: The ID of the message
|
||||
#
|
||||
# Response: A array of hashes containing delivery information
|
||||
# OR an error if the message does not exist.
|
||||
#
|
||||
def deliveries
|
||||
message = find_message
|
||||
return if performed?
|
||||
|
||||
deliveries = message.deliveries.map do |d|
|
||||
{
|
||||
id: d.id,
|
||||
status: d.status,
|
||||
details: d.details,
|
||||
output: d.output&.strip,
|
||||
sent_with_ssl: d.sent_with_ssl,
|
||||
log_id: d.log_id,
|
||||
time: d.time&.to_f,
|
||||
timestamp: d.timestamp.to_f
|
||||
}
|
||||
end
|
||||
render_success deliveries
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
render_error "MessageNotFound",
|
||||
message: "No message found matching provided ID",
|
||||
id: api_params["id"]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Look up the message referenced by the request's `id` parameter.
|
||||
#
|
||||
# The legacy API only ever identifies a message by its integer ID. The
|
||||
# request body is parsed as JSON, so without validation a JSON object or
|
||||
# array supplied for `id` would arrive as a Ruby Hash/Array and be passed
|
||||
# straight through to the message database as a raw set of SQL conditions.
|
||||
# We therefore reject anything that is not a simple scalar before it can
|
||||
# reach the database and coerce the value to an integer ID.
|
||||
#
|
||||
# Renders an error and returns nil when the parameter is missing or is not
|
||||
# a scalar; otherwise returns the matched message (raising NotFound when no
|
||||
# message matches, which the actions rescue).
|
||||
#
|
||||
# @return [Postal::MessageDB::Message, nil]
|
||||
def find_message
|
||||
id = api_params["id"]
|
||||
|
||||
if id.blank?
|
||||
render_parameter_error "`id` parameter is required but is missing"
|
||||
return
|
||||
end
|
||||
|
||||
unless id.is_a?(String) || id.is_a?(Integer)
|
||||
render_parameter_error "`id` parameter must be a string or integer"
|
||||
return
|
||||
end
|
||||
|
||||
@current_credential.server.message(id.to_i)
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,135 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module LegacyAPI
|
||||
class SendController < BaseController
|
||||
|
||||
ERROR_MESSAGES = {
|
||||
"NoRecipients" => "There are no recipients defined to receive this message",
|
||||
"NoContent" => "There is no content defined for this e-mail",
|
||||
"TooManyToAddresses" => "The maximum number of To addresses has been reached (maximum 50)",
|
||||
"TooManyCCAddresses" => "The maximum number of CC addresses has been reached (maximum 50)",
|
||||
"TooManyBCCAddresses" => "The maximum number of BCC addresses has been reached (maximum 50)",
|
||||
"FromAddressMissing" => "The From address is missing and is required",
|
||||
"UnauthenticatedFromAddress" => "The From address is not authorised to send mail from this server",
|
||||
"AttachmentMissingName" => "An attachment is missing a name",
|
||||
"AttachmentMissingData" => "An attachment is missing data"
|
||||
}.freeze
|
||||
|
||||
# Send a message with the given options
|
||||
#
|
||||
# URL: /api/v1/send/message
|
||||
#
|
||||
# Parameters: to => REQ: An array of emails addresses
|
||||
# cc => An array of email addresses to CC
|
||||
# bcc => An array of email addresses to BCC
|
||||
# from => The name/email to send the email from
|
||||
# sender => The name/email of the 'Sender'
|
||||
# reply_to => The name/email of the 'Reply-to'
|
||||
# plain_body => The plain body
|
||||
# html_body => The HTML body
|
||||
# bounce => Is this message a bounce?
|
||||
# tag => A custom tag to add to the message
|
||||
# custom_headers => A hash of custom headers
|
||||
# attachments => An array of attachments
|
||||
# (name, content_type and data (base64))
|
||||
#
|
||||
# Response: A array of hashes containing message information
|
||||
# OR an error if there is an issue sending the message
|
||||
#
|
||||
def message
|
||||
attributes = {}
|
||||
attributes[:to] = api_params["to"]
|
||||
attributes[:cc] = api_params["cc"]
|
||||
attributes[:bcc] = api_params["bcc"]
|
||||
attributes[:from] = api_params["from"]
|
||||
attributes[:sender] = api_params["sender"]
|
||||
attributes[:subject] = api_params["subject"]
|
||||
attributes[:reply_to] = api_params["reply_to"]
|
||||
attributes[:plain_body] = api_params["plain_body"]
|
||||
attributes[:html_body] = api_params["html_body"]
|
||||
attributes[:bounce] = api_params["bounce"] ? true : false
|
||||
attributes[:tag] = api_params["tag"]
|
||||
attributes[:custom_headers] = api_params["headers"] if api_params["headers"]
|
||||
attributes[:attachments] = []
|
||||
|
||||
(api_params["attachments"] || []).each do |attachment|
|
||||
next unless attachment.is_a?(Hash)
|
||||
|
||||
attributes[:attachments] << { name: attachment["name"], content_type: attachment["content_type"], data: attachment["data"], base64: true }
|
||||
end
|
||||
|
||||
message = OutgoingMessagePrototype.new(@current_credential.server, request.ip, "api", attributes)
|
||||
message.credential = @current_credential
|
||||
if message.valid?
|
||||
result = message.create_messages
|
||||
render_success message_id: message.message_id, messages: result
|
||||
else
|
||||
render_error message.errors.first, message: ERROR_MESSAGES[message.errors.first]
|
||||
end
|
||||
end
|
||||
|
||||
# Send a message by providing a raw message
|
||||
#
|
||||
# URL: /api/v1/send/raw
|
||||
#
|
||||
# Parameters: rcpt_to => REQ: An array of email addresses to send
|
||||
# the message to
|
||||
# mail_from => REQ: the address to send the email from
|
||||
# data => REQ: base64-encoded mail data
|
||||
#
|
||||
# Response: A array of hashes containing message information
|
||||
# OR an error if there is an issue sending the message
|
||||
#
|
||||
def raw
|
||||
unless api_params["rcpt_to"].is_a?(Array)
|
||||
render_parameter_error "`rcpt_to` parameter is required but is missing"
|
||||
return
|
||||
end
|
||||
|
||||
if api_params["mail_from"].blank?
|
||||
render_parameter_error "`mail_from` parameter is required but is missing"
|
||||
return
|
||||
end
|
||||
|
||||
if api_params["data"].blank?
|
||||
render_parameter_error "`data` parameter is required but is missing"
|
||||
return
|
||||
end
|
||||
|
||||
# Decode the raw message
|
||||
raw_message = Base64.decode64(api_params["data"])
|
||||
|
||||
# Parse through mail to get the from/sender headers
|
||||
mail = Mail.new(raw_message.split("\r\n\r\n", 2).first)
|
||||
from_headers = { "from" => mail.from, "sender" => mail.sender }
|
||||
authenticated_domain = @current_credential.server.find_authenticated_domain_from_headers(from_headers)
|
||||
|
||||
# If we're not authenticated, don't continue
|
||||
if authenticated_domain.nil?
|
||||
render_error "UnauthenticatedFromAddress"
|
||||
return
|
||||
end
|
||||
|
||||
# Store the result ready to return
|
||||
result = { message_id: nil, messages: {} }
|
||||
if api_params["rcpt_to"].is_a?(Array)
|
||||
api_params["rcpt_to"].uniq.each do |rcpt_to|
|
||||
message = @current_credential.server.message_db.new_message
|
||||
message.rcpt_to = rcpt_to
|
||||
message.mail_from = api_params["mail_from"]
|
||||
message.raw_message = raw_message
|
||||
message.received_with_ssl = true
|
||||
message.scope = "outgoing"
|
||||
message.domain_id = authenticated_domain.id
|
||||
message.credential_id = @current_credential.id
|
||||
message.bounce = api_params["bounce"] ? true : false
|
||||
message.save
|
||||
result[:message_id] = message.message_id if result[:message_id].nil?
|
||||
result[:messages][rcpt_to] = { id: message.id, token: message.token }
|
||||
end
|
||||
end
|
||||
render_success result
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -89,6 +89,18 @@ class MessagesController < ApplicationController
|
||||
end
|
||||
|
||||
def html_raw
|
||||
override_content_security_policy_directives(
|
||||
default_src: %w('none'),
|
||||
script_src: %w('none'),
|
||||
style_src: %w('unsafe-inline'),
|
||||
img_src: %w(* data:),
|
||||
font_src: %w(*),
|
||||
frame_ancestors: %w('self'),
|
||||
form_action: %w('none'),
|
||||
base_uri: %w('none')
|
||||
)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
render html: @message.html_body_without_tracking_image.html_safe
|
||||
end
|
||||
|
||||
|
||||
@@ -43,10 +43,11 @@ class OrganizationsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
unless current_user.authenticate(params[:password])
|
||||
if params[:confirm_text].blank? || params[:confirm_text].downcase.strip != organization.name.downcase.strip
|
||||
respond_to do |wants|
|
||||
wants.html { redirect_to organization_delete_path(@organization), alert: "The password you entered was not valid. Please check and try again." }
|
||||
wants.json { render json: { alert: "The password you entered was invalid. Please check and try again." } }
|
||||
alert_text = "The text you entered does not match the organization name. Please check and try again."
|
||||
wants.html { redirect_to organization_delete_path(@organization), alert: alert_text }
|
||||
wants.json { render json: { alert: alert_text } }
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -45,8 +45,16 @@ class ServersController < ApplicationController
|
||||
extra_params = [:spam_threshold, :spam_failure_threshold, :postmaster_address]
|
||||
|
||||
if current_user.admin?
|
||||
extra_params += [:send_limit, :allow_sender, :privacy_mode, :log_smtp_data, :outbound_spam_threshold,
|
||||
:message_retention_days, :raw_message_retention_days, :raw_message_retention_size]
|
||||
extra_params += [
|
||||
:send_limit,
|
||||
:allow_sender,
|
||||
:privacy_mode,
|
||||
:log_smtp_data,
|
||||
:outbound_spam_threshold,
|
||||
:message_retention_days,
|
||||
:raw_message_retention_days,
|
||||
:raw_message_retention_size,
|
||||
]
|
||||
end
|
||||
|
||||
if @server.update(safe_params(*extra_params))
|
||||
@@ -57,23 +65,21 @@ class ServersController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
unless current_user.authenticate(params[:password])
|
||||
if params[:confirm_text].blank? || params[:confirm_text].downcase.strip != @server.name.downcase.strip
|
||||
respond_to do |wants|
|
||||
wants.html do
|
||||
redirect_to [:delete, organization, @server], alert: "The password you entered was not valid. Please check and try again."
|
||||
end
|
||||
wants.json do
|
||||
render json: { alert: "The password you entere was invalid. Please check and try again" }
|
||||
end
|
||||
alert_text = "The text you entered does not match the server name. Please check and try again."
|
||||
wants.html { redirect_to organization_delete_path(@organization), alert: alert_text }
|
||||
wants.json { render json: { alert: alert_text } }
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@server.soft_destroy
|
||||
redirect_to_with_json organization_root_path(organization), notice: "#{@server.name} has been deleted successfully"
|
||||
end
|
||||
|
||||
def queue
|
||||
@messages = @server.queued_messages.order(id: :desc).page(params[:page])
|
||||
@messages = @server.queued_messages.order(id: :desc).page(params[:page]).includes(:ip_address)
|
||||
@messages_with_message = @messages.include_message
|
||||
end
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ class SessionsController < ApplicationController
|
||||
|
||||
layout "sub"
|
||||
|
||||
skip_before_action :login_required, only: [:new, :create, :begin_password_reset, :finish_password_reset, :ip, :raise_error]
|
||||
before_action :require_local_authentication, only: [:create, :begin_password_reset, :finish_password_reset]
|
||||
skip_before_action :login_required, only: [:new, :create, :begin_password_reset, :finish_password_reset, :ip, :raise_error, :create_from_oidc, :oauth_failure]
|
||||
|
||||
def create
|
||||
login(User.authenticate(params[:email_address], params[:password]))
|
||||
@@ -29,12 +30,16 @@ class SessionsController < ApplicationController
|
||||
def begin_password_reset
|
||||
return unless request.post?
|
||||
|
||||
if user = User.where(email_address: params[:email_address]).first
|
||||
user.begin_password_reset(params[:return_to])
|
||||
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."
|
||||
else
|
||||
redirect_to login_reset_path(return_to: params[:return_to]), alert: "No user exists with that e-mail address. Please check and try again."
|
||||
user_scope = Postal::Config.oidc.enabled? ? User.with_password : User
|
||||
user = user_scope.find_by(email_address: params[:email_address])
|
||||
|
||||
if user.nil?
|
||||
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."
|
||||
return
|
||||
end
|
||||
|
||||
user.begin_password_reset(params[:return_to])
|
||||
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."
|
||||
end
|
||||
|
||||
def finish_password_reset
|
||||
@@ -49,6 +54,7 @@ class SessionsController < ApplicationController
|
||||
flash.now[:alert] = "You must enter a new password"
|
||||
return
|
||||
end
|
||||
|
||||
@user.password = params[:password]
|
||||
@user.password_confirmation = params[:password_confirmation]
|
||||
return unless @user.save
|
||||
@@ -61,4 +67,33 @@ class SessionsController < ApplicationController
|
||||
render plain: "ip: #{request.ip} remote ip: #{request.remote_ip}"
|
||||
end
|
||||
|
||||
def create_from_oidc
|
||||
unless Postal::Config.oidc.enabled?
|
||||
raise Postal::Error, "OIDC cannot be used unless enabled in the configuration"
|
||||
end
|
||||
|
||||
auth = request.env["omniauth.auth"]
|
||||
user = User.find_from_oidc(auth.extra.raw_info, logger: Postal.logger)
|
||||
if user.nil?
|
||||
redirect_to login_path, alert: "No user was found matching your identity. Please contact your administrator."
|
||||
return
|
||||
end
|
||||
|
||||
login(user)
|
||||
flash[:remember_login] = true
|
||||
redirect_to_with_return_to root_path
|
||||
end
|
||||
|
||||
def oauth_failure
|
||||
redirect_to login_path, alert: "An issue occurred while logging you in with OpenID. Please try again later or contact your administrator."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_local_authentication
|
||||
return if Postal::Config.oidc.local_authentication_enabled?
|
||||
|
||||
redirect_to login_path, alert: "Local authentication is not enabled"
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -30,23 +30,28 @@ class UserController < ApplicationController
|
||||
|
||||
def update
|
||||
@user = User.find(current_user.id)
|
||||
@user.attributes = params.require(:user).permit(:first_name, :last_name, :time_zone, :email_address, :password, :password_confirmation)
|
||||
safe_params = [:first_name, :last_name, :time_zone, :email_address]
|
||||
|
||||
if @user.authenticate_with_previous_password_first(params[:password])
|
||||
@password_correct = true
|
||||
else
|
||||
respond_to do |wants|
|
||||
wants.html do
|
||||
flash.now[:alert] = "The current password you have entered is incorrect. Please check and try again."
|
||||
render "edit"
|
||||
end
|
||||
wants.json do
|
||||
render json: { alert: "The current password you've entered is incorrect. Please check and try again" }
|
||||
if @user.password? && Postal::Config.oidc.local_authentication_enabled?
|
||||
safe_params += [:password, :password_confirmation]
|
||||
if @user.authenticate_with_previous_password_first(params[:password])
|
||||
@password_correct = true
|
||||
else
|
||||
respond_to do |wants|
|
||||
wants.html do
|
||||
flash.now[:alert] = "The current password you have entered is incorrect. Please check and try again."
|
||||
render "edit"
|
||||
end
|
||||
wants.json do
|
||||
render json: { alert: "The current password you've entered is incorrect. Please check and try again" }
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@user.attributes = params.require(:user).permit(safe_params)
|
||||
|
||||
if @user.save
|
||||
redirect_to_with_json settings_path, notice: "Your settings have been updated successfully."
|
||||
else
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WellKnownController < ApplicationController
|
||||
|
||||
layout false
|
||||
|
||||
skip_before_action :set_browser_id
|
||||
skip_before_action :login_required
|
||||
skip_before_action :set_timezone
|
||||
|
||||
def jwks
|
||||
render json: JWT::JWK::Set.new(Postal.signer.jwk).export.to_json
|
||||
end
|
||||
|
||||
end
|
||||
@@ -3,6 +3,7 @@
|
||||
module ApplicationHelper
|
||||
|
||||
def format_delivery_details(server, text)
|
||||
text = h(text)
|
||||
text.gsub!(/<msg:(\d+)>/) do
|
||||
id = ::Regexp.last_match(1).to_i
|
||||
link_to("message ##{id}", organization_server_message_path(server.organization, server, id), class: "u-link")
|
||||
@@ -32,7 +33,7 @@ module ApplicationHelper
|
||||
s << "<optgroup label='Server Domains'>"
|
||||
server_domains.each do |domain|
|
||||
selected = domain == selected_domain ? "selected='selected'" : ""
|
||||
s << "<option value='#{domain.id}' #{selected}>#{domain.name}</option>"
|
||||
s << "<option value='#{h(domain.id)}' #{selected}>#{h(domain.name)}</option>"
|
||||
end
|
||||
s << "</optgroup>"
|
||||
end
|
||||
@@ -42,7 +43,7 @@ module ApplicationHelper
|
||||
s << "<optgroup label='Organization Domains'>"
|
||||
organization_domains.each do |domain|
|
||||
selected = domain == selected_domain ? "selected='selected'" : ""
|
||||
s << "<option value='#{domain.id}' #{selected}>#{domain.name}</option>"
|
||||
s << "<option value='#{h(domain.id)}' #{selected}>#{h(domain.name)}</option>"
|
||||
end
|
||||
s << "</optgroup>"
|
||||
end
|
||||
@@ -59,7 +60,7 @@ module ApplicationHelper
|
||||
http_endpoints.each do |endpoint|
|
||||
value = "#{endpoint.class}##{endpoint.uuid}"
|
||||
selected = value == selected_value ? "selected='selected'" : ""
|
||||
s << "<option value='#{value}' #{selected}>#{endpoint.description}</option>"
|
||||
s << "<option value='#{h(value)}' #{selected}>#{h(endpoint.description)}</option>"
|
||||
end
|
||||
s << "</optgroup>"
|
||||
end
|
||||
@@ -70,7 +71,7 @@ module ApplicationHelper
|
||||
smtp_endpoints.each do |endpoint|
|
||||
value = "#{endpoint.class}##{endpoint.uuid}"
|
||||
selected = value == selected_value ? "selected='selected'" : ""
|
||||
s << "<option value='#{value}' #{selected}>#{endpoint.description}</option>"
|
||||
s << "<option value='#{h(value)}' #{selected}>#{h(endpoint.description)}</option>"
|
||||
end
|
||||
s << "</optgroup>"
|
||||
end
|
||||
@@ -81,7 +82,7 @@ module ApplicationHelper
|
||||
address_endpoints.each do |endpoint|
|
||||
value = "#{endpoint.class}##{endpoint.uuid}"
|
||||
selected = value == selected_value ? "selected='selected'" : ""
|
||||
s << "<option value='#{value}' #{selected}>#{endpoint.address}</option>"
|
||||
s << "<option value='#{h(value)}' #{selected}>#{h(endpoint.address)}</option>"
|
||||
end
|
||||
s << "</optgroup>"
|
||||
end
|
||||
@@ -93,11 +94,18 @@ module ApplicationHelper
|
||||
|
||||
selected = (selected_value == mode ? "selected='selected'" : "")
|
||||
text = t("route_modes.#{mode.underscore}")
|
||||
s << "<option value='#{mode}' #{selected}>#{text}</option>"
|
||||
s << "<option value='#{h(mode)}' #{selected}>#{h(text)}</option>"
|
||||
end
|
||||
s << "</optgroup>"
|
||||
end
|
||||
end.html_safe
|
||||
end
|
||||
|
||||
def postal_version_string
|
||||
string = Postal.version
|
||||
string += " (#{Postal.branch})" if Postal.branch &&
|
||||
Postal.branch != "main"
|
||||
string
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ErrorMessagesHelper
|
||||
|
||||
# Renders a record's validation errors.
|
||||
#
|
||||
# This replaces the helper of the same name from the dynamic_form gem, which
|
||||
# is unmaintained and cannot be installed on Ruby 3.4. The markup matches what
|
||||
# that gem produced, minus the heading and description which the
|
||||
# errorExplanation styles have always hidden, and matches the structure
|
||||
# ajax.coffee builds when it renders errors from a remote form.
|
||||
def error_messages_for(record)
|
||||
record = record.to_model if record.respond_to?(:to_model)
|
||||
return "".html_safe if record.nil? || record.errors.empty?
|
||||
|
||||
items = safe_join(record.errors.full_messages.map { |message| tag.li(message) })
|
||||
tag.div(tag.ul(items), id: "errorExplanation", class: "errorExplanation")
|
||||
end
|
||||
|
||||
end
|
||||
@@ -9,7 +9,7 @@ class DKIMHeader
|
||||
@dkim_identifier = domain.dkim_identifier
|
||||
else
|
||||
@domain_name = Postal::Config.dns.return_path_domain
|
||||
@dkim_key = Postal.signing_key
|
||||
@dkim_key = Postal.signer.private_key
|
||||
@dkim_identifier = Postal::Config.dns.dkim_identifier
|
||||
end
|
||||
@domain = domain
|
||||
|
||||
@@ -111,9 +111,11 @@ class DNSResolver
|
||||
def dns(raise_timeout_errors: false)
|
||||
Resolv::DNS.open(nameserver: @nameservers,
|
||||
raise_timeout_errors: raise_timeout_errors) do |dns|
|
||||
dns.timeouts = [Postal::Config.dns.timeout,
|
||||
Postal::Config.dns.timeout / 2,
|
||||
Postal::Config.dns.timeout / 2]
|
||||
dns.timeouts = [
|
||||
Postal::Config.dns.timeout,
|
||||
Postal::Config.dns.timeout / 2,
|
||||
Postal::Config.dns.timeout / 2,
|
||||
]
|
||||
yield dns
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
module MessageDequeuer
|
||||
class InitialProcessor < Base
|
||||
|
||||
include HasPrometheusMetrics
|
||||
|
||||
attr_accessor :send_result
|
||||
|
||||
def process
|
||||
@@ -10,6 +12,7 @@ module MessageDequeuer
|
||||
logger.info "starting message unqueue"
|
||||
begin
|
||||
catch_stops do
|
||||
increment_dequeue_metric
|
||||
check_message_exists
|
||||
check_message_is_ready
|
||||
find_other_messages_for_batch
|
||||
@@ -17,7 +20,7 @@ module MessageDequeuer
|
||||
# Process the original message and then all of those
|
||||
# found for batching.
|
||||
process_message(@queued_message)
|
||||
@other_messages.each { |message| process_message(message) }
|
||||
@other_messages&.each { |message| process_message(message) }
|
||||
end
|
||||
ensure
|
||||
@state.finished
|
||||
@@ -28,9 +31,16 @@ module MessageDequeuer
|
||||
|
||||
private
|
||||
|
||||
def increment_dequeue_metric
|
||||
time_in_queue = Time.now.to_f - @queued_message.created_at.to_f
|
||||
log "queue latency is #{time_in_queue}s"
|
||||
observe_prometheus_histogram :postal_message_queue_latency,
|
||||
time_in_queue
|
||||
end
|
||||
|
||||
def check_message_exists
|
||||
@queued_message.message
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
return if @queued_message.message
|
||||
|
||||
log "unqueue because backend message has been removed."
|
||||
remove_from_queue
|
||||
stop_processing
|
||||
@@ -45,6 +55,8 @@ module MessageDequeuer
|
||||
end
|
||||
|
||||
def find_other_messages_for_batch
|
||||
return unless Postal::Config.postal.batch_queued_messages?
|
||||
|
||||
@other_messages = @queued_message.batchable_messages(100)
|
||||
log "found #{@other_messages.size} associated messages to process at the same time", batch_key: @queued_message.batch_key
|
||||
rescue StandardError
|
||||
|
||||
@@ -31,8 +31,8 @@ module MessageDequeuer
|
||||
private
|
||||
|
||||
def check_message_exists
|
||||
queued_message.message
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
return if queued_message.message
|
||||
|
||||
log "unqueueing because backend message has been removed"
|
||||
remove_from_queue
|
||||
stop_processing
|
||||
|
||||
@@ -13,7 +13,7 @@ class ReplySeparator
|
||||
/^>*\s*On.{10,200}wrote:\s*$.*/m,
|
||||
/^>*\s*Sent from my.*/m,
|
||||
/^>*\s*=== Please reply above this line ===.*/m,
|
||||
/(^>.*\n?){10,}/
|
||||
/(^>.*\n?){10,}/,
|
||||
].freeze
|
||||
|
||||
def self.separate(text)
|
||||
|
||||
@@ -116,18 +116,19 @@ module SMTPServer
|
||||
private
|
||||
|
||||
def proxy(data)
|
||||
# inet-protocol, client-ip, proxy-ip, client-port, proxy-port
|
||||
if m = data.match(/\APROXY (.+) (.+) (.+) (.+) (.+)\z/)
|
||||
@ip_address = m[2]
|
||||
check_ip_address
|
||||
@state = :welcome
|
||||
logger&.debug "\e[35mClient identified as #{@ip_address}\e[0m"
|
||||
increment_command_count("PROXY")
|
||||
"220 #{Postal::Config.postal.smtp_hostname} ESMTP Postal/#{id}"
|
||||
else
|
||||
@finished = true
|
||||
increment_error_count("proxy-error")
|
||||
"502 Proxy Error"
|
||||
return "220 #{Postal::Config.postal.smtp_hostname} ESMTP Postal/#{trace_id}"
|
||||
end
|
||||
|
||||
@finished = true
|
||||
increment_error_count("proxy-error")
|
||||
"502 Proxy Error"
|
||||
end
|
||||
|
||||
def quit
|
||||
@@ -155,7 +156,7 @@ module SMTPServer
|
||||
[
|
||||
"250-My capabilities are",
|
||||
Postal::Config.smtp_server.tls_enabled? && !@tls ? "250-STARTTLS" : nil,
|
||||
"250 AUTH CRAM-MD5 PLAIN LOGIN"
|
||||
"250 AUTH CRAM-MD5 PLAIN LOGIN",
|
||||
].compact
|
||||
end
|
||||
|
||||
@@ -252,7 +253,7 @@ module SMTPServer
|
||||
org_permlink, server_permalink = username.split(/[\/_]/, 2)
|
||||
server = ::Server.includes(:organization).where(organizations: { permalink: org_permlink }, permalink: server_permalink).first
|
||||
if server.nil?
|
||||
logger&.warn "Authentication failure for #{@ip_address}"
|
||||
logger&.warn "Authentication failure for #{@ip_address} (no server found matching #{username})"
|
||||
increment_error_count("invalid-credentials")
|
||||
next "535 Denied"
|
||||
end
|
||||
@@ -264,12 +265,13 @@ module SMTPServer
|
||||
|
||||
@credential = credential
|
||||
@credential.use
|
||||
logger&.debug "Authenticated with with credential #{credential.id}"
|
||||
grant = "235 Granted for #{credential.server.organization.permalink}/#{credential.server.permalink}"
|
||||
break
|
||||
end
|
||||
|
||||
if grant.nil?
|
||||
logger&.warn "Authentication failure for #{@ip_address}"
|
||||
logger&.warn "Authentication failure for #{@ip_address} (invalid credential)"
|
||||
increment_error_count("invalid-credentials")
|
||||
next "535 Denied"
|
||||
end
|
||||
|
||||
@@ -26,7 +26,7 @@ module Worker
|
||||
# @return [Array<Class>]
|
||||
JOBS = [
|
||||
Jobs::ProcessQueuedMessagesJob,
|
||||
Jobs::ProcessWebhookRequestsJob
|
||||
Jobs::ProcessWebhookRequestsJob,
|
||||
].freeze
|
||||
|
||||
# An array of tasks that should be processed
|
||||
@@ -40,11 +40,14 @@ module Worker
|
||||
ProcessMessageRetentionScheduledTask,
|
||||
PruneSuppressionListsScheduledTask,
|
||||
PruneWebhookRequestsScheduledTask,
|
||||
SendNotificationsScheduledTask
|
||||
SendNotificationsScheduledTask,
|
||||
TidyQueuedMessagesTask,
|
||||
].freeze
|
||||
|
||||
# @param [Integer] thread_count The number of worker threads to run in this process
|
||||
def initialize(thread_count: 2, work_sleep_time: 5, task_sleep_time: 60)
|
||||
def initialize(thread_count: Postal::Config.worker.threads,
|
||||
work_sleep_time: 5,
|
||||
task_sleep_time: 60)
|
||||
@thread_count = thread_count
|
||||
@exit_pipe_read, @exit_pipe_write = IO.pipe
|
||||
@work_sleep_time = work_sleep_time
|
||||
@@ -57,6 +60,7 @@ module Worker
|
||||
def run
|
||||
logger.tagged(component: "worker") do
|
||||
setup_traps
|
||||
ensure_connection_pool_size_is_suitable
|
||||
start_work_threads
|
||||
start_tasks_thread
|
||||
wait_for_threads
|
||||
@@ -93,6 +97,23 @@ module Worker
|
||||
@exit_pipe_read.wait_readable(wait_time) ? true : false
|
||||
end
|
||||
|
||||
# Ensure that the connection pool is big enough for the number of threads
|
||||
# configured.
|
||||
#
|
||||
# @return [void]
|
||||
def ensure_connection_pool_size_is_suitable
|
||||
current_pool_size = ActiveRecord::Base.connection_pool.size
|
||||
desired_pool_size = @thread_count + 3
|
||||
|
||||
return if current_pool_size >= desired_pool_size
|
||||
|
||||
logger.warn "number of worker threads (#{@thread_count}) is more " \
|
||||
"than the db connection pool size (#{current_pool_size}+3), " \
|
||||
"increasing connection pool size to #{desired_pool_size}"
|
||||
|
||||
Postal.change_database_connection_pool_size(desired_pool_size)
|
||||
end
|
||||
|
||||
# Wait for all threads to complete
|
||||
#
|
||||
# @return [void]
|
||||
@@ -181,7 +202,7 @@ module Worker
|
||||
logger.info "stopping tasks thread"
|
||||
ActiveRecord::Base.connection_pool.with_connection do
|
||||
if WorkerRole.release(:tasks)
|
||||
logger.info "releasesd tasks role"
|
||||
logger.info "released tasks role"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -277,7 +298,7 @@ module Worker
|
||||
labels: [:thread, :job]
|
||||
|
||||
register_prometheus_histogram :postal_worker_job_runtime,
|
||||
docstring: "The time taken to process jobs",
|
||||
docstring: "The time taken to process jobs (in seconds)",
|
||||
labels: [:thread, :job]
|
||||
|
||||
register_prometheus_counter :postal_worker_errors,
|
||||
@@ -285,8 +306,11 @@ module Worker
|
||||
labels: [:error]
|
||||
|
||||
register_prometheus_histogram :postal_worker_task_runtime,
|
||||
docstring: "The time taken to process tasks",
|
||||
docstring: "The time taken to process tasks (in seconds)",
|
||||
labels: [:task]
|
||||
|
||||
register_prometheus_histogram :postal_message_queue_latency,
|
||||
docstring: "The length of time between a message being queued and being dequeued (in seconds)"
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -5,15 +5,20 @@ module HasAuthentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_secure_password
|
||||
has_secure_password validations: false
|
||||
|
||||
validates :password, length: { minimum: 8, allow_blank: true }
|
||||
validates :password, confirmation: { allow_blank: true }
|
||||
validate :validate_password_presence
|
||||
|
||||
before_save :clear_password_reset_token_on_password_change
|
||||
|
||||
scope :with_password, -> { where.not(password_digest: nil) }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def authenticate(email_address, password)
|
||||
user = where(email_address: email_address).first
|
||||
user = find_by(email_address: email_address)
|
||||
raise Postal::Errors::AuthenticationError, "InvalidEmailAddress" if user.nil?
|
||||
raise Postal::Errors::AuthenticationError, "InvalidPassword" unless user.authenticate(password)
|
||||
|
||||
@@ -30,6 +35,10 @@ module HasAuthentication
|
||||
end
|
||||
|
||||
def begin_password_reset(return_to = nil)
|
||||
if Postal::Config.oidc.enabled? && (oidc_uid.present? || password_digest.blank?)
|
||||
raise Postal::Error, "User has OIDC enabled, password resets are not supported"
|
||||
end
|
||||
|
||||
self.password_reset_token = SecureRandom.alphanumeric(24)
|
||||
self.password_reset_token_valid_until = 1.day.from_now
|
||||
save!
|
||||
@@ -45,6 +54,12 @@ module HasAuthentication
|
||||
self.password_reset_token_valid_until = nil
|
||||
end
|
||||
|
||||
def validate_password_presence
|
||||
return if password_digest.present? || Postal::Config.oidc.enabled?
|
||||
|
||||
errors.add :password, :blank
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# -*- SkipSchemaAnnotations
|
||||
|
||||
@@ -7,7 +7,11 @@ module HasMessage
|
||||
end
|
||||
|
||||
def message
|
||||
@message ||= server.message_db.message(message_id)
|
||||
return @message if instance_variable_defined?("@message")
|
||||
|
||||
@message = server.message_db.message(message_id)
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
@message = nil
|
||||
end
|
||||
|
||||
def message=(message)
|
||||
|
||||
@@ -31,7 +31,7 @@ class Credential < ApplicationRecord
|
||||
validate :validate_key_cannot_be_changed
|
||||
validate :validate_key_for_smtp_ip
|
||||
|
||||
serialize :options, Hash
|
||||
serialize :options, type: Hash, coder: YAML
|
||||
|
||||
before_validation :generate_key
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "uri"
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: http_endpoints
|
||||
@@ -38,6 +40,7 @@ class HTTPEndpoint < ApplicationRecord
|
||||
|
||||
validates :name, presence: true
|
||||
validates :url, presence: true
|
||||
validate :url_must_be_http_or_https
|
||||
validates :encoding, inclusion: { in: ENCODINGS }
|
||||
validates :format, inclusion: { in: FORMATS }
|
||||
validates :timeout, numericality: { greater_than_or_equal_to: 5, less_than_or_equal_to: 60 }
|
||||
@@ -56,4 +59,17 @@ class HTTPEndpoint < ApplicationRecord
|
||||
routes.each { |r| r.update(endpoint: nil, mode: "Reject") }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def url_must_be_http_or_https
|
||||
return if url.blank?
|
||||
|
||||
uri = URI.parse(url)
|
||||
return if uri.is_a?(URI::HTTP) && uri.host.present?
|
||||
|
||||
errors.add(:url, "must be an HTTP or HTTPS URL")
|
||||
rescue URI::InvalidURIError
|
||||
errors.add(:url, "must be a valid HTTP or HTTPS URL")
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -99,7 +99,7 @@ class OutgoingMessagePrototype
|
||||
{
|
||||
name: attachment[:name],
|
||||
content_type: attachment[:content_type] || "application/octet-stream",
|
||||
data: attachment[:base64] ? Base64.decode64(attachment[:data]) : attachment[:data]
|
||||
data: attachment[:base64] && attachment[:data] ? Base64.decode64(attachment[:data]) : attachment[:data]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,14 +33,14 @@ class QueuedMessage < ApplicationRecord
|
||||
|
||||
belongs_to :server
|
||||
belongs_to :ip_address, optional: true
|
||||
belongs_to :user, optional: true
|
||||
|
||||
before_create :allocate_ip_address
|
||||
|
||||
scope :ready_with_delayed_retry, -> { where("retry_after IS NULL OR retry_after < ?", 30.seconds.ago) }
|
||||
scope :with_stale_lock, -> { where("locked_at IS NOT NULL AND locked_at < ?", Postal::Config.postal.queued_message_lock_stale_days.days.ago) }
|
||||
|
||||
def retry_now
|
||||
update(retry_after: nil)
|
||||
update!(retry_after: nil)
|
||||
end
|
||||
|
||||
def send_bounce
|
||||
@@ -50,7 +50,11 @@ class QueuedMessage < ApplicationRecord
|
||||
end
|
||||
|
||||
def allocate_ip_address
|
||||
return unless Postal.ip_pools? && message && pool = server.ip_pool_for_message(message)
|
||||
return unless Postal.ip_pools?
|
||||
return if message.nil?
|
||||
|
||||
pool = server.ip_pool_for_message(message)
|
||||
return if pool.nil?
|
||||
|
||||
self.ip_address = pool.ip_addresses.select_by_priority
|
||||
end
|
||||
|
||||
+81
-20
@@ -5,19 +5,21 @@
|
||||
# Table name: users
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# uuid :string(255)
|
||||
# first_name :string(255)
|
||||
# last_name :string(255)
|
||||
# admin :boolean default(FALSE)
|
||||
# email_address :string(255)
|
||||
# password_digest :string(255)
|
||||
# time_zone :string(255)
|
||||
# email_verification_token :string(255)
|
||||
# email_verified_at :datetime
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# first_name :string(255)
|
||||
# last_name :string(255)
|
||||
# oidc_issuer :string(255)
|
||||
# oidc_uid :string(255)
|
||||
# password_digest :string(255)
|
||||
# password_reset_token :string(255)
|
||||
# password_reset_token_valid_until :datetime
|
||||
# admin :boolean default(FALSE)
|
||||
# time_zone :string(255)
|
||||
# uuid :string(255)
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -28,13 +30,11 @@
|
||||
class User < ApplicationRecord
|
||||
|
||||
include HasUUID
|
||||
|
||||
include HasAuthentication
|
||||
|
||||
validates :first_name, presence: true
|
||||
validates :last_name, presence: true
|
||||
validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: /@/, allow_blank: true }
|
||||
validates :time_zone, presence: true
|
||||
|
||||
default_value :time_zone, -> { "UTC" }
|
||||
|
||||
@@ -53,24 +53,85 @@ class User < ApplicationRecord
|
||||
"#{first_name} #{last_name}"
|
||||
end
|
||||
|
||||
def password?
|
||||
password_digest.present?
|
||||
end
|
||||
|
||||
def oidc?
|
||||
oidc_uid.present?
|
||||
end
|
||||
|
||||
def to_param
|
||||
uuid
|
||||
end
|
||||
|
||||
def md5_for_gravatar
|
||||
@md5_for_gravatar ||= Digest::MD5.hexdigest(email_address.to_s.downcase)
|
||||
end
|
||||
|
||||
def avatar_url
|
||||
@avatar_url ||= email_address ? "https://secure.gravatar.com/avatar/#{md5_for_gravatar}?rating=PG&size=120&d=mm" : nil
|
||||
end
|
||||
|
||||
def email_tag
|
||||
"#{name} <#{email_address}>"
|
||||
end
|
||||
|
||||
def self.[](email)
|
||||
where(email_address: email).first
|
||||
class << self
|
||||
|
||||
# Lookup a user by email address
|
||||
#
|
||||
# @param email [String] the email address
|
||||
#
|
||||
# @return [User, nil] the user
|
||||
def [](email)
|
||||
find_by(email_address: email)
|
||||
end
|
||||
|
||||
# Find a user based on an OIDC authentication hash
|
||||
#
|
||||
# @param auth [Hash] the authentication hash
|
||||
# @param logger [Logger] a logger to log debug information to
|
||||
#
|
||||
# @return [User, nil] the user
|
||||
def find_from_oidc(auth, logger: nil)
|
||||
config = Postal::Config.oidc
|
||||
|
||||
uid = auth[config.uid_field]
|
||||
oidc_name = auth[config.name_field]
|
||||
oidc_email_address = auth[config.email_address_field]
|
||||
|
||||
logger&.debug "got auth details from issuer: #{auth.inspect}"
|
||||
|
||||
# look for an existing user with the same UID and OIDC issuer. If we find one,
|
||||
# this is the user we'll want to use.
|
||||
user = where(oidc_uid: uid, oidc_issuer: config.issuer).first
|
||||
|
||||
if user
|
||||
logger&.debug "found user with UID #{uid} for issuer #{config.issuer} (user ID: #{user.id})"
|
||||
else
|
||||
logger&.debug "no user with UID #{uid} for issuer #{config.issuer}"
|
||||
end
|
||||
|
||||
# if we don't have an existing user, we will look for users which have no OIDC
|
||||
# credentials but with a matching e-mail address.
|
||||
if user.nil? && oidc_email_address.present?
|
||||
user = where(oidc_uid: nil, email_address: oidc_email_address).first
|
||||
if user
|
||||
logger&.debug "found user with e-mail address #{oidc_email_address} (user ID: #{user.id})"
|
||||
else
|
||||
logger&.debug "no user with e-mail address #{oidc_email_address}"
|
||||
end
|
||||
end
|
||||
|
||||
# now, if we still don't have a user, we're not going to create one so we'll just
|
||||
# return nil (we might auto create users in the future but not right now)
|
||||
return if user.nil?
|
||||
|
||||
# otherwise, let's update our user as appropriate
|
||||
user.oidc_uid = uid
|
||||
user.oidc_issuer = config.issuer
|
||||
user.email_address = oidc_email_address if oidc_email_address.present?
|
||||
user.first_name, user.last_name = oidc_name.split(/\s+/, 2) if oidc_name.present?
|
||||
user.password = nil
|
||||
user.save!
|
||||
|
||||
# return the user
|
||||
user
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -34,7 +34,7 @@ class WebhookRequest < ApplicationRecord
|
||||
validates :url, presence: true
|
||||
validates :event, presence: true
|
||||
|
||||
serialize :payload, Hash
|
||||
serialize :payload, type: Hash, coder: YAML
|
||||
|
||||
class << self
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ class ApplicationScheduledTask
|
||||
end
|
||||
|
||||
def call
|
||||
# override me
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
attr_reader :logger
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
class ProcessMessageRetentionScheduledTask < ApplicationScheduledTask
|
||||
|
||||
def perform
|
||||
def call
|
||||
Server.all.each do |server|
|
||||
if server.raw_message_retention_days
|
||||
# If the server has a maximum number of retained raw messages, remove any that are older than this
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TidyQueuedMessagesTask < ApplicationScheduledTask
|
||||
|
||||
def call
|
||||
QueuedMessage.with_stale_lock.in_batches do |messages|
|
||||
messages.each do |message|
|
||||
logger.info "removing queued message #{message.id} (locked at #{message.locked_at} by #{message.locked_by})"
|
||||
message.destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.next_run_after
|
||||
quarter_to_each_hour
|
||||
end
|
||||
|
||||
end
|
||||
@@ -244,11 +244,11 @@ class SMTPSender < BaseSender
|
||||
relays = Postal::Config.postal.smtp_relays
|
||||
return nil if relays.nil?
|
||||
|
||||
relays.map do |relay|
|
||||
relays = relays.filter_map do |relay|
|
||||
next unless relay.host.present?
|
||||
|
||||
SMTPClient::Server.new(relay.host, relay.port, ssl_mode: relay.ssl_mode)
|
||||
end.compact
|
||||
SMTPClient::Server.new(relay.host, port: relay.port, ssl_mode: relay.ssl_mode)
|
||||
end
|
||||
|
||||
@smtp_relays = relays.empty? ? nil : relays
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "socket"
|
||||
require "rack/handler/webrick"
|
||||
require "rackup/handler/webrick"
|
||||
require "prometheus/client/formats/text"
|
||||
|
||||
class HealthServer
|
||||
@@ -55,11 +55,11 @@ class HealthServer
|
||||
port = ENV.fetch("HEALTH_SERVER_PORT", default_port)
|
||||
bind_address = ENV.fetch("HEALTH_SERVER_BIND_ADDRESS", default_bind_address)
|
||||
|
||||
Rack::Handler::WEBrick.run(new(**options),
|
||||
Port: port,
|
||||
BindAddress: bind_address,
|
||||
AccessLog: [],
|
||||
Logger: LoggerProxy.new)
|
||||
Rackup::Handler::WEBrick.run(new(**options),
|
||||
Port: port,
|
||||
BindAddress: bind_address,
|
||||
AccessLog: [],
|
||||
Logger: LoggerProxy.new)
|
||||
rescue Errno::EADDRINUSE
|
||||
Postal.logger.info "health server port (#{bind_address}:#{port}) is already " \
|
||||
"in use, not starting health server"
|
||||
@@ -95,7 +95,7 @@ class HealthServer
|
||||
Postal.logger.info "stopped health server", component: "health-server"
|
||||
when /\AWEBrick [\d.]+/,
|
||||
/\Aruby ([\d.]+)/,
|
||||
/\ARack::Handler::WEBrick is mounted/,
|
||||
/\ARackup::Handler::WEBrick is mounted/,
|
||||
/\Aclose TCPSocket/,
|
||||
/\Agoing to shutdown/
|
||||
# Don't actually print routine messages to avoid too much
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
= f.select :hold, [["Process all messages", false], ["Hold messages from this credential", true]], {}, :class => 'input input--select'
|
||||
%p.fieldSet__text
|
||||
You may wish to automatically hold all messages that are sent by this credential. This allows you to preview them
|
||||
for they are delivered to their recipients. This is useful for credentials for development environments.
|
||||
before they are delivered to their recipients. This is useful for credentials for development environments.
|
||||
|
||||
.fieldSetSubmit.buttonSet
|
||||
= f.submit @credential.new_record? ? "Create credential" : "Save credential", :class => 'button button--positive js-form-submit'
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
%footer.siteContent__footer
|
||||
%ul.footer__links
|
||||
%li.footer__name
|
||||
Powered by #{link_to "Postal", "https://postalserver.io", target: '_blank'} #{Postal.version}.
|
||||
Powered by
|
||||
#{link_to "Postal", "https://postalserver.io", target: '_blank'}
|
||||
#{postal_version_string}
|
||||
%li= link_to "Documentation", "https://docs.postalserver.io", target: '_blank'
|
||||
%li= link_to "Ask for help", "https://discussions.postalserver.io", target: '_blank'
|
||||
|
||||
@@ -1,24 +1,50 @@
|
||||
%ul.messageList
|
||||
- for message in messages
|
||||
|
||||
- if message.is_a?(QueuedMessage)
|
||||
- queued_message = message
|
||||
- message = message.message
|
||||
%li.messageList__message
|
||||
= link_to organization_server_message_path(organization, @server, message.id), :class => 'messageList__link' do
|
||||
.messageList__details{:class => 'messageList__details--' + message.scope}
|
||||
%p.messageList__subject= message.subject || "No subject"
|
||||
%dl.messageList__addresses
|
||||
%dt To
|
||||
%dd
|
||||
- if message.rcpt_to_return_path?
|
||||
%span.returnPathTag Return Path
|
||||
- else
|
||||
= message.rcpt_to || "none"
|
||||
%dt From
|
||||
%dd= message.mail_from || "none"
|
||||
|
||||
|
||||
- if message.nil? && queued_message
|
||||
%li.messageList__message
|
||||
.messageList__link
|
||||
.messageList__details
|
||||
%p.messageList__subject Deleted message ##{queued_message.message_id}
|
||||
%dl.messageList__addresses
|
||||
%dt Domain
|
||||
%dd= queued_message.domain
|
||||
%dt Locked
|
||||
%dd= queued_message.locked? ? "Yes" : "No"
|
||||
.messageList__meta
|
||||
%p.messageList__timestamp= queued_message.created_at.in_time_zone.to_fs(:long)
|
||||
%p.messageList__status
|
||||
%span.label{:class => "label--messageStatus-deleted"} Deleted
|
||||
|
||||
.messageList__meta
|
||||
%p.messageList__timestamp= message.timestamp.in_time_zone.to_fs(:long)
|
||||
%p.messageList__status
|
||||
- if message.read?
|
||||
%span.label.label--purple Opened
|
||||
%span.label{:class => "label--messageStatus-#{message.status.underscore}"}= message.status.underscore.humanize
|
||||
|
||||
- else
|
||||
%li.messageList__message
|
||||
= link_to organization_server_message_path(organization, @server, message.id), :class => 'messageList__link' do
|
||||
.messageList__details{:class => 'messageList__details--' + message.scope}
|
||||
%p.messageList__subject= message.subject || "No subject"
|
||||
%dl.messageList__addresses
|
||||
%dt To
|
||||
%dd
|
||||
- if message.rcpt_to_return_path?
|
||||
%span.returnPathTag Return Path
|
||||
- else
|
||||
= message.rcpt_to || "none"
|
||||
%dt From
|
||||
%dd= message.mail_from || "none"
|
||||
- if queued_message
|
||||
%dt Attempts
|
||||
%dd= queued_message.attempts
|
||||
%dt Retry after
|
||||
%dd= queued_message.retry_after&.to_fs(:short) || "ASAP"
|
||||
|
||||
.messageList__meta
|
||||
%p.messageList__timestamp= message.timestamp.in_time_zone.to_fs(:long)
|
||||
%p.messageList__status
|
||||
- if message.read?
|
||||
%span.label.label--purple Opened
|
||||
%span.label{:class => "label--messageStatus-#{message.status.underscore}"}= message.status.underscore.humanize
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
This means that we no longer store the raw data for this e-mail
|
||||
or the e-mail didn't include a HTML part.
|
||||
- else
|
||||
%iframe{:width => "100%", :height => "100%", :src => html_raw_organization_server_message_path(organization, @server, @message.id)}
|
||||
%iframe{:width => "100%", :height => "100%", :sandbox => "allow-popups allow-popups-to-escape-sandbox", :referrerpolicy => "no-referrer", :src => html_raw_organization_server_message_path(organization, @server, @message.id)}
|
||||
|
||||
@@ -13,12 +13,11 @@
|
||||
all its mail servers & data will be deleted from our systems.
|
||||
.dangerZone
|
||||
%p.pageContent__text.u-margin
|
||||
To continue to delete this organization, please enter your password in the field below and press
|
||||
To continue to delete this organization, please enter the name of the organization in the field below and press
|
||||
continue. <b class='u-red'>There will be no other confirmations.</b>
|
||||
= form_tag [organization, :delete], :method => :delete, :remote => true do
|
||||
= hidden_field_tag 'return_to', params[:return_to]
|
||||
%p.u-margin
|
||||
= password_field_tag "password", '', :class => 'input input--text input--danger'
|
||||
= text_field_tag "confirm_text", '', :class => 'input input--text input--danger'
|
||||
.buttonSet.u-center
|
||||
= submit_tag "Delete this organization, mail servers and all messages", :class => 'button button--danger js-form-submit'
|
||||
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
immediately.
|
||||
.dangerZone
|
||||
%p.pageContent__text.u-margin
|
||||
To continue to remove this server, please enter your password in the field below and press
|
||||
To continue to remove this server, please enter the server name in the field below and press
|
||||
continue. <b class='u-red'>There will be no other confirmations.</b>
|
||||
= form_tag [organization, @server], :remote => true, :method => :delete do
|
||||
= hidden_field_tag 'return_to', params[:return_to]
|
||||
%p.u-margin
|
||||
= password_field_tag "password", '', :class => 'input input--text input--danger'
|
||||
= text_field_tag "confirm_text", '', :class => 'input input--text input--danger'
|
||||
.buttonSet.u-center
|
||||
= submit_tag "Delete this mail server and all messages", :class => 'button button--danger'
|
||||
|
||||
|
||||
@@ -6,13 +6,19 @@
|
||||
.subPageBox__content
|
||||
= form_tag login_path, :class => 'loginForm' do
|
||||
= hidden_field_tag 'return_to', params[:return_to]
|
||||
- if params[:return_to] && params[:return_to] =~ /\/join\//
|
||||
%p.loginForm__invite.warningBox.u-margin Login to your existing account to accept your invitation.
|
||||
|
||||
%p.loginForm__input= text_field_tag 'email_address', '', :type => 'email', :autocomplete => 'off', :spellcheck => 'false', :class => 'input input--text input--onWhite', :placeholder => "Your e-mail address", :autofocus => true, :tabindex => 1
|
||||
%p.loginForm__input= password_field_tag 'password', '', :class => 'input input--text input--onWhite', :placeholder => "Your password", :tabindex => 2
|
||||
.loginForm__submit
|
||||
%ul.loginForm__links
|
||||
%li= link_to "Forgotten your password?", login_reset_path(:return_to => params[:return_to])
|
||||
%p= submit_tag "Login", :class => 'button button--positive', :tabindex => 3
|
||||
- if Postal::Config.oidc.enabled?
|
||||
.loginForm__oidcButton
|
||||
= link_to "Login with #{Postal::Config.oidc.name}", "/auth/oidc", method: :post, class: 'button button--full'
|
||||
|
||||
- if Postal::Config.oidc.enabled? && Postal::Config.oidc.local_authentication_enabled?
|
||||
.loginForm__divider
|
||||
%p.loginForm__localTitle or login with a local user
|
||||
|
||||
- if Postal::Config.oidc.local_authentication_enabled?
|
||||
%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
|
||||
%p.loginForm__input= password_field_tag 'password', '', :class => 'input input--text input--onWhite', :placeholder => "Your password", :tabindex => 2
|
||||
.loginForm__submit
|
||||
%ul.loginForm__links
|
||||
%li= link_to "Forgotten your password?", login_reset_path(:return_to => params[:return_to])
|
||||
%p= submit_tag "Login", :class => 'button button--positive', :tabindex => 3
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
= form_for @user, :url => settings_path, :remote => true do |f|
|
||||
= f.error_messages
|
||||
%fieldset.fieldSet
|
||||
.fieldSet__field
|
||||
= label_tag :password, 'Your Password', :class => 'fieldSet__label'
|
||||
.fieldSet__input
|
||||
= 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"
|
||||
- if @password_correct
|
||||
= hidden_field_tag :password, params[:password]
|
||||
%p.fieldSet__text
|
||||
In order to protect your account, you need to enter your current password in the field above
|
||||
to authenticate the change of your details.
|
||||
- if @user.password? && Postal::Config.oidc.local_authentication_enabled?
|
||||
.fieldSet__field
|
||||
= label_tag :password, 'Your Password', :class => 'fieldSet__label'
|
||||
.fieldSet__input
|
||||
= 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"
|
||||
- if @password_correct
|
||||
= hidden_field_tag :password, params[:password]
|
||||
%p.fieldSet__text
|
||||
In order to protect your account, you need to enter your current password in the field above
|
||||
to authenticate the change of your details.
|
||||
|
||||
.fieldSet__title
|
||||
Your details
|
||||
@@ -41,14 +42,15 @@
|
||||
Choose the time zone that you'd like times to be displayed to you when you use our
|
||||
web interface. By default, times are displayed in UTC.
|
||||
|
||||
.fieldSet__title
|
||||
Change your password?
|
||||
.fieldSet__field
|
||||
= f.label :password, "New Password", :class => 'fieldSet__label'
|
||||
.fieldSet__input
|
||||
.inputPair
|
||||
= f.password_field :password, :class => 'input input--text', :placeholder => "•••••••••••", :value => @user.password
|
||||
= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => "and confirm it", :value => @user.password_confirmation
|
||||
- if @user.password? && Postal::Config.oidc.local_authentication_enabled?
|
||||
.fieldSet__title
|
||||
Change your password?
|
||||
.fieldSet__field
|
||||
= f.label :password, "New Password", :class => 'fieldSet__label'
|
||||
.fieldSet__input
|
||||
.inputPair
|
||||
= f.password_field :password, :class => 'input input--text', :placeholder => "•••••••••••", :value => @user.password
|
||||
= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => "and confirm it", :value => @user.password_confirmation
|
||||
|
||||
|
||||
%p.fieldSetSubmit.buttonSet
|
||||
|
||||
@@ -11,11 +11,25 @@
|
||||
.fieldSet__input= f.text_field :last_name, :class => 'input input--text'
|
||||
.fieldSet__field
|
||||
= f.label :email_address, :class => 'fieldSet__label'
|
||||
.fieldSet__input= f.text_field :email_address, :class => 'input input--text', autocomplete: 'one-time-code'
|
||||
- unless @user.persisted?
|
||||
.fieldSet__input
|
||||
= f.text_field :email_address, :class => 'input input--text', autocomplete: 'one-time-code'
|
||||
- if Postal::Config.oidc.enabled?
|
||||
%p.fieldSet__text
|
||||
This e-mail address should match the address provided by your OpenID Connect identity provider.
|
||||
|
||||
|
||||
- if Postal::Config.oidc.local_authentication_enabled? && !@user.persisted?
|
||||
.fieldSet__field
|
||||
= f.label :password, :class => 'fieldSet__label'
|
||||
.fieldSet__input= f.password_field :password, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'
|
||||
.fieldSet__input
|
||||
= f.password_field :password, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'
|
||||
- if Postal::Config.oidc.enabled?
|
||||
%p.fieldSet__text
|
||||
You have enabled OIDC which means a password is not required. If you do not provide
|
||||
a password this user will be matched to an OIDC identity based on the e-mail address
|
||||
provided above. You may, however, enter a password and this user will be permitted to
|
||||
use that password until they have successfully logged in with OIDC.
|
||||
|
||||
.fieldSet__field
|
||||
= f.label :password_confirmation, "Confirm".html_safe, :class => 'fieldSet__label'
|
||||
.fieldSet__input= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'
|
||||
|
||||
@@ -7,15 +7,20 @@
|
||||
%ul.userList.u-margin
|
||||
- for user in @users
|
||||
%li.userList__item
|
||||
= image_tag user.avatar_url, :class => 'userList__avatar'
|
||||
.userList__details
|
||||
%p.userList__name
|
||||
= user.name
|
||||
- if user.admin?
|
||||
%span.userList__admin.label Admin
|
||||
%span.userList__tag.label.label--blue Admin
|
||||
- if Postal::Config.oidc.enabled?
|
||||
- if user.oidc?
|
||||
%span.userList__tag.label.label--green OIDC
|
||||
- elsif !Postal::Config.oidc.local_authentication_enabled?
|
||||
%span.userList__tag.label.label--orange Pending
|
||||
|
||||
%p.userList__email= user.email_address
|
||||
%ul.userList__actions
|
||||
%li= link_to "Edit permissions", [:edit, user]
|
||||
%li= link_to "Edit user", [:edit, user]
|
||||
%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'
|
||||
|
||||
%p.u-center= link_to "Add a new user", :new_user, :class => 'button button--positive'
|
||||
|
||||
@@ -12,7 +12,9 @@ require "sprockets/railtie"
|
||||
|
||||
# Require the gems listed in Gemfile, including any gems
|
||||
# you've limited to :test, :development, or :production.
|
||||
Bundler.require(*Rails.groups)
|
||||
gem_groups = Rails.groups
|
||||
gem_groups << :oidc if Postal::Config.oidc.enabled?
|
||||
Bundler.require(*gem_groups)
|
||||
|
||||
module Postal
|
||||
class Application < Rails::Application
|
||||
|
||||
@@ -6,7 +6,7 @@ Rails.application.configure do
|
||||
# In the development environment your application's code is reloaded on
|
||||
# every request. This slows down response time but is perfect for development
|
||||
# since you don't have to restart the web server when you make code changes.
|
||||
config.cache_classes = false
|
||||
config.enable_reloading = true
|
||||
|
||||
# Do not eager load code on boot.
|
||||
config.eager_load = false
|
||||
|
||||
@@ -4,7 +4,7 @@ Rails.application.configure do
|
||||
# Settings specified here will take precedence over those in config/application.rb.
|
||||
|
||||
# Code is not reloaded between requests.
|
||||
config.cache_classes = true
|
||||
config.enable_reloading = false
|
||||
|
||||
# Eager load code on boot. This eager loads most of Rails and
|
||||
# your application in memory, allowing both threaded web servers
|
||||
|
||||
@@ -7,7 +7,7 @@ Rails.application.configure do
|
||||
# test suite. You never need to work with it otherwise. Remember that
|
||||
# your test database is "scratch space" for the test suite and is wiped
|
||||
# and recreated between test runs. Don't rely on the data there!
|
||||
config.cache_classes = true
|
||||
config.enable_reloading = false
|
||||
|
||||
# Do not eager load code on boot. This avoids loading your whole application
|
||||
# just for the purpose of running a single test. If you are using a tool that
|
||||
@@ -25,7 +25,7 @@ Rails.application.configure do
|
||||
config.action_controller.perform_caching = false
|
||||
|
||||
# Raise exceptions instead of rendering exception templates.
|
||||
config.action_dispatch.show_exceptions = false
|
||||
config.action_dispatch.show_exceptions = :none
|
||||
|
||||
# Disable request forgery protection in test environment.
|
||||
config.action_controller.allow_forgery_protection = false
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported
|
||||
# notations and behaviors.
|
||||
Rails.application.config.filter_parameters += [
|
||||
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
|
||||
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Provides `f.error_messages` on form builders. This used to come from the
|
||||
# dynamic_form gem - see ErrorMessagesHelper for why that was removed.
|
||||
ActiveSupport.on_load(:action_view) do
|
||||
ActionView::Helpers::FormBuilder.class_eval do
|
||||
def error_messages
|
||||
@template.error_messages_for(@object)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -16,6 +16,7 @@
|
||||
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
||||
inflect.acronym "DKIM"
|
||||
inflect.acronym "HTTP"
|
||||
inflect.acronym "OIDC"
|
||||
inflect.acronym "SMTP"
|
||||
inflect.acronym "UUID"
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
# Be sure to restart your server when you modify this file.
|
||||
#
|
||||
# This file eases your Rails 7.0 framework defaults upgrade.
|
||||
#
|
||||
# Uncomment each configuration one by one to switch to the new default.
|
||||
# Once your application is ready to run with all new defaults, you can remove
|
||||
# this file and set the `config.load_defaults` to `7.0`.
|
||||
#
|
||||
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
|
||||
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
|
||||
|
||||
# `button_to` view helper will render `<button>` element, regardless of whether
|
||||
# or not the content is passed as the first argument or as a block.
|
||||
# Rails.application.config.action_view.button_to_generates_button_tag = true
|
||||
|
||||
# `stylesheet_link_tag` view helper will not render the media attribute by default.
|
||||
# Rails.application.config.action_view.apply_stylesheet_media_default = false
|
||||
|
||||
# Change the digest class for the key generators to `OpenSSL::Digest::SHA256`.
|
||||
# Changing this default means invalidate all encrypted messages generated by
|
||||
# your application and, all the encrypted cookies. Only change this after you
|
||||
# rotated all the messages using the key rotator.
|
||||
#
|
||||
# See upgrading guide for more information on how to build a rotator.
|
||||
# https://guides.rubyonrails.org/v7.0/upgrading_ruby_on_rails.html
|
||||
# Rails.application.config.active_support.key_generator_hash_digest_class = OpenSSL::Digest::SHA256
|
||||
|
||||
# Change the digest class for ActiveSupport::Digest.
|
||||
# Changing this default means that for example Etags change and
|
||||
# various cache keys leading to cache invalidation.
|
||||
# Rails.application.config.active_support.hash_digest_class = OpenSSL::Digest::SHA256
|
||||
|
||||
# Don't override ActiveSupport::TimeWithZone.name and use the default Ruby
|
||||
# implementation.
|
||||
# Rails.application.config.active_support.remove_deprecated_time_with_zone_name = true
|
||||
|
||||
# Calls `Rails.application.executor.wrap` around test cases.
|
||||
# This makes test cases behave closer to an actual request or job.
|
||||
# Several features that are normally disabled in test, such as Active Record query cache
|
||||
# and asynchronous queries will then be enabled.
|
||||
# Rails.application.config.active_support.executor_around_test_case = true
|
||||
|
||||
# Set both the `:open_timeout` and `:read_timeout` values for `:smtp` delivery method.
|
||||
# Rails.application.config.action_mailer.smtp_timeout = 5
|
||||
|
||||
# The ActiveStorage video previewer will now use scene change detection to generate
|
||||
# better preview images (rather than the previous default of using the first frame
|
||||
# of the video).
|
||||
# Rails.application.config.active_storage.video_preview_arguments =
|
||||
# "-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"
|
||||
|
||||
# Automatically infer `inverse_of` for associations with a scope.
|
||||
# Rails.application.config.active_record.automatic_scope_inversing = true
|
||||
|
||||
# Raise when running tests if fixtures contained foreign key violations
|
||||
# Rails.application.config.active_record.verify_foreign_keys_for_fixtures = true
|
||||
|
||||
# Disable partial inserts.
|
||||
# This default means that all columns will be referenced in INSERT queries
|
||||
# regardless of whether they have a default or not.
|
||||
# Rails.application.config.active_record.partial_inserts = false
|
||||
|
||||
# Protect from open redirect attacks in `redirect_back_or_to` and `redirect_to`.
|
||||
# Rails.application.config.action_controller.raise_on_open_redirects = true
|
||||
|
||||
# Change the variant processor for Active Storage.
|
||||
# Changing this default means updating all places in your code that
|
||||
# generate variants to use image processing macros and ruby-vips
|
||||
# operations. See the upgrading guide for detail on the changes required.
|
||||
# The `:mini_magick` option is not deprecated; it's fine to keep using it.
|
||||
# Rails.application.config.active_storage.variant_processor = :vips
|
||||
|
||||
# Enable parameter wrapping for JSON.
|
||||
# Previously this was set in an initializer. It's fine to keep using that initializer if you've customized it.
|
||||
# To disable parameter wrapping entirely, set this config to `false`.
|
||||
# Rails.application.config.action_controller.wrap_parameters_by_default = true
|
||||
|
||||
# Specifies whether generated namespaced UUIDs follow the RFC 4122 standard for namespace IDs provided as a
|
||||
# `String` to `Digest::UUID.uuid_v3` or `Digest::UUID.uuid_v5` method calls.
|
||||
#
|
||||
# See https://guides.rubyonrails.org/configuring.html#config-active-support-use-rfc4122-namespaced-uuids for
|
||||
# more information.
|
||||
# Rails.application.config.active_support.use_rfc4122_namespaced_uuids = true
|
||||
|
||||
# Change the default headers to disable browsers' flawed legacy XSS protection.
|
||||
# Rails.application.config.action_dispatch.default_headers = {
|
||||
# "X-Frame-Options" => "SAMEORIGIN",
|
||||
# "X-XSS-Protection" => "0",
|
||||
# "X-Content-Type-Options" => "nosniff",
|
||||
# "X-Download-Options" => "noopen",
|
||||
# "X-Permitted-Cross-Domain-Policies" => "none",
|
||||
# "Referrer-Policy" => "strict-origin-when-cross-origin"
|
||||
# }
|
||||
|
||||
# ** Please read carefully, this must be configured in config/application.rb **
|
||||
# Change the format of the cache entry.
|
||||
# Changing this default means that all new cache entries added to the cache
|
||||
# will have a different format that is not supported by Rails 6.1 applications.
|
||||
# Only change this value after your application is fully deployed to Rails 7.0
|
||||
# and you have no plans to rollback.
|
||||
# When you're ready to change format, add this to `config/application.rb` (NOT this file):
|
||||
# config.active_support.cache_format_version = 7.0
|
||||
|
||||
# Cookie serializer: 2 options
|
||||
#
|
||||
# If you're upgrading and haven't set `cookies_serializer` previously, your cookie serializer
|
||||
# is `:marshal`. The default for new apps is `:json`.
|
||||
#
|
||||
# Rails.application.config.action_dispatch.cookies_serializer = :json
|
||||
#
|
||||
#
|
||||
# To migrate an existing application to the `:json` serializer, use the `:hybrid` option.
|
||||
#
|
||||
# Rails transparently deserializes existing (Marshal-serialized) cookies on read and
|
||||
# re-writes them in the JSON format.
|
||||
#
|
||||
# It is fine to use `:hybrid` long term; you should do that until you're confident *all* your cookies
|
||||
# have been converted to JSON. To keep using `:hybrid` long term, move this config to its own
|
||||
# initializer or to `config/application.rb`.
|
||||
#
|
||||
# Rails.application.config.action_dispatch.cookies_serializer = :hybrid
|
||||
#
|
||||
#
|
||||
# If your cookies can't yet be serialized to JSON, keep using `:marshal` for backward-compatibility.
|
||||
#
|
||||
# If you have configured the serializer elsewhere, you can remove this section of the file.
|
||||
#
|
||||
# See https://guides.rubyonrails.org/action_controller_overview.html#cookies for more information.
|
||||
|
||||
# Change the return value of `ActionDispatch::Request#content_type` to the Content-Type header without modification.
|
||||
# Rails.application.config.action_dispatch.return_only_request_media_type_on_content_type = false
|
||||
|
||||
# Active Storage `has_many_attached` relationships will default to replacing the current collection instead of appending to it.
|
||||
# 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`.
|
||||
# See https://guides.rubyonrails.org/configuring.html#config-active-storage-multiple-file-field-include-hidden for more information.
|
||||
# Rails.application.config.active_storage.multiple_file_field_include_hidden = true
|
||||
|
||||
# ** Please read carefully, this must be configured in config/application.rb (NOT this file) **
|
||||
# Disables the deprecated #to_s override in some Ruby core classes
|
||||
# See https://guides.rubyonrails.org/configuring.html#config-active-support-disable-to-s-conversion for more information.
|
||||
# config.active_support.disable_to_s_conversion = true
|
||||
@@ -0,0 +1,200 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Be sure to restart your server when you modify this file.
|
||||
#
|
||||
# This file eases your Rails 7.1 framework defaults upgrade.
|
||||
#
|
||||
# Uncomment each configuration one by one to switch to the new default.
|
||||
# Once your application is ready to run with all new defaults, you can remove
|
||||
# this file and set the `config.load_defaults` to `7.1`.
|
||||
#
|
||||
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
|
||||
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
|
||||
|
||||
###
|
||||
# No longer add autoloaded paths into `$LOAD_PATH`. This means that you won't be able
|
||||
# to manually require files that are managed by the autoloader, which you shouldn't do anyway.
|
||||
#
|
||||
# This will reduce the size of the load path, making `require` faster if you don't use bootsnap, or reduce the size
|
||||
# of the bootsnap cache if you use it.
|
||||
#++
|
||||
# Rails.application.config.add_autoload_paths_to_load_path = false
|
||||
|
||||
###
|
||||
# Remove the default X-Download-Options header since it is used only by Internet Explorer.
|
||||
# If you need to support Internet Explorer, add back `"X-Download-Options" => "noopen"`.
|
||||
#++
|
||||
# Rails.application.config.action_dispatch.default_headers = {
|
||||
# "X-Frame-Options" => "SAMEORIGIN",
|
||||
# "X-XSS-Protection" => "0",
|
||||
# "X-Content-Type-Options" => "nosniff",
|
||||
# "X-Permitted-Cross-Domain-Policies" => "none",
|
||||
# "Referrer-Policy" => "strict-origin-when-cross-origin"
|
||||
# }
|
||||
|
||||
###
|
||||
# Do not treat an `ActiveSupport::Duration` as a number of seconds when it is
|
||||
# used as a cache expiry. Raise instead, because the intent is ambiguous.
|
||||
#++
|
||||
# Rails.application.config.active_support.raise_on_invalid_cache_expiration_time = true
|
||||
|
||||
###
|
||||
# Specify the default serializer used by `MessageEncryptor` and `MessageVerifier`
|
||||
# instances.
|
||||
#
|
||||
# The legacy default is `:marshal`, which is a potential vector for
|
||||
# deserialization attacks in cases where a message signing secret has been
|
||||
# leaked.
|
||||
#
|
||||
# In Rails 7.1, the new default is `:json_allow_marshal`, which serializes and
|
||||
# deserializes with `ActiveSupport::JSON`, but can fall back to deserializing
|
||||
# with `Marshal` so that legacy messages can still be read.
|
||||
#
|
||||
# In Rails 7.2, the default will become `:json`, which serializes and
|
||||
# deserializes with `ActiveSupport::JSON` only.
|
||||
#
|
||||
# Alternatively, you can choose `:message_pack` or `:message_pack_allow_marshal`,
|
||||
# which serialize with `ActiveSupport::MessagePack`. `ActiveSupport::MessagePack`
|
||||
# can roundtrip some Ruby types that are not supported by JSON, and may provide
|
||||
# improved performance, but it requires the `msgpack` gem.
|
||||
#
|
||||
# For more information, see
|
||||
# https://guides.rubyonrails.org/v7.1/configuring.html#config-active-support-message-serializer
|
||||
#
|
||||
# NOTE: Changing this affects the session cookie and any other signed or
|
||||
# encrypted message. Deploy `:json_allow_marshal` everywhere before considering
|
||||
# `:json`, and be aware that rolling back to a version which cannot read the new
|
||||
# format will invalidate existing sessions.
|
||||
#++
|
||||
# Rails.application.config.active_support.message_serializer = :json_allow_marshal
|
||||
|
||||
###
|
||||
# Enable a performance optimization that serializes message data and metadata
|
||||
# together. This changes the message format, so messages serialized this way
|
||||
# cannot be read by older versions of Rails. However, messages that use the old
|
||||
# format can still be read, regardless of whether this optimization is enabled.
|
||||
#
|
||||
# To perform a rolling deploy of a Rails 7.1 upgrade, wherein servers that have
|
||||
# not yet been upgraded must be able to read messages from upgraded servers, leave
|
||||
# this optimization off on the first deploy, then enable it on a subsequent deploy.
|
||||
#++
|
||||
# Rails.application.config.active_support.use_message_serializer_for_metadata = true
|
||||
|
||||
###
|
||||
# Set the maximum size for Rails log files.
|
||||
#
|
||||
# `config.load_defaults "7.1"` does not set this value for environments other than
|
||||
# development and test.
|
||||
#++
|
||||
# if Rails.env.local?
|
||||
# Rails.application.config.log_file_size = 100 * 1024 * 1024
|
||||
# end
|
||||
|
||||
###
|
||||
# Enable raising on assignment to attr_readonly attributes. The previous behavior
|
||||
# would allow assignment but silently not persist changes to the database.
|
||||
#++
|
||||
# Rails.application.config.active_record.raise_on_assign_to_attr_readonly = true
|
||||
|
||||
###
|
||||
# Enable validating only parent-related columns for presence when the parent is mandatory.
|
||||
# The previous behavior was to validate the presence of the parent record, which performed an extra query
|
||||
# to get the parent every time the child record was updated, even when parent has not changed.
|
||||
#++
|
||||
# Rails.application.config.active_record.belongs_to_required_validates_foreign_key = false
|
||||
|
||||
###
|
||||
# Enable preloading of the `before_committed!` callbacks on all records in a transaction,
|
||||
# instead of only the first copy of a record.
|
||||
#++
|
||||
# Rails.application.config.active_record.before_committed_on_all_records = true
|
||||
|
||||
###
|
||||
# Disable deprecated singular associations names.
|
||||
#++
|
||||
# Rails.application.config.active_record.allow_deprecated_singular_associations_name = false
|
||||
|
||||
###
|
||||
# Enable running `after_commit` callbacks in the order they are defined,
|
||||
# rather than in reverse order.
|
||||
#++
|
||||
# Rails.application.config.active_record.run_after_transaction_callbacks_in_order_defined = true
|
||||
|
||||
###
|
||||
# Run `after_commit` and `after_rollback` callbacks on the first saved instance
|
||||
# of a record in a transaction, rather than the instance which was saved last.
|
||||
#++
|
||||
# Rails.application.config.active_record.run_commit_callbacks_on_first_saved_instances_in_transaction = false
|
||||
|
||||
###
|
||||
# Configure the query log tags to use the SQLCommenter format.
|
||||
#++
|
||||
# Rails.application.config.active_record.query_log_tags_format = :sqlcommenter
|
||||
|
||||
###
|
||||
# Specify the default serializer used by `ActiveRecord::Base#serialize`.
|
||||
#
|
||||
# `nil` means there is no default, so every `serialize` call must declare its
|
||||
# `coder:` explicitly. This app already does that, so this option is safe to
|
||||
# enable. Do not remove those explicit coders.
|
||||
#++
|
||||
# Rails.application.config.active_record.default_column_serializer = nil
|
||||
|
||||
###
|
||||
# Use a 7.1-compatible marshalling format when caching Active Record objects.
|
||||
#
|
||||
# This format is more efficient, but cannot be read by Rails 7.0 and earlier, so
|
||||
# do not enable this until every process is running 7.1 or later.
|
||||
#++
|
||||
# Rails.application.config.active_record.marshalling_format_version = 7.1
|
||||
|
||||
###
|
||||
# Generate a `has_secure_token` value when the record is initialized rather than
|
||||
# when it is created.
|
||||
#++
|
||||
# Rails.application.config.active_record.generate_secure_token_on = :initialize
|
||||
|
||||
###
|
||||
# Use a modern HTML5 sanitizer (via Nokogiri's HTML5 parser) rather than the
|
||||
# legacy HTML4 one, where it is supported.
|
||||
#
|
||||
# This changes how malformed markup is parsed and sanitized. Review anywhere the
|
||||
# application sanitizes user-supplied HTML before enabling it.
|
||||
#++
|
||||
# Rails.application.config.action_view.sanitizer_vendor = Rails::HTML::Sanitizer.best_supported_vendor
|
||||
|
||||
###
|
||||
# Parse HTML in tests with an HTML5 parser rather than an HTML4 one.
|
||||
#++
|
||||
# Rails.application.config.dom_testing_default_html_version = :html5
|
||||
|
||||
###
|
||||
# Precompile the filter parameters (`config.filter_parameters`) so that filtering
|
||||
# is faster at request time.
|
||||
#++
|
||||
# Rails.application.config.precompile_filter_parameters = true
|
||||
|
||||
###
|
||||
# Log exceptions that are handled by `rescue_from` (or otherwise rendered as an
|
||||
# error page) at the `error` level rather than `fatal`.
|
||||
#++
|
||||
# Rails.application.config.action_dispatch.debug_exception_log_level = :error
|
||||
|
||||
###
|
||||
# Change the cache entry format, so that new entries are written in a format
|
||||
# which Rails 7.0 and earlier cannot read.
|
||||
#
|
||||
# Only enable this once every process is running 7.1 or later, and you have no
|
||||
# plans to roll back.
|
||||
#++
|
||||
# Rails.application.config.active_support.cache_format_version = 7.1
|
||||
|
||||
###
|
||||
# Use SHA-256 to derive Active Record Encryption keys and stop supporting SHA-1
|
||||
# for non-deterministic encryption.
|
||||
#
|
||||
# This app does not currently use Active Record Encryption. If that changes,
|
||||
# read the upgrade guide before enabling these.
|
||||
#++
|
||||
# Rails.application.config.active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA256
|
||||
# Rails.application.config.active_record.encryption.support_sha1_for_non_deterministic_encryption = false
|
||||
@@ -0,0 +1,72 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Be sure to restart your server when you modify this file.
|
||||
#
|
||||
# This file eases your Rails 7.2 framework defaults upgrade.
|
||||
#
|
||||
# Uncomment each configuration one by one to switch to the new default.
|
||||
# Once your application is ready to run with all new defaults, you can remove
|
||||
# this file and set the `config.load_defaults` to `7.2`.
|
||||
#
|
||||
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
|
||||
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
|
||||
|
||||
###
|
||||
# Controls whether Active Job's `#perform_later` and similar methods automatically defer
|
||||
# the job queuing to after the current Active Record transaction is committed.
|
||||
#
|
||||
# Example:
|
||||
# Topic.transaction do
|
||||
# topic = Topic.create(...)
|
||||
# NewTopicNotificationJob.perform_later(topic)
|
||||
# end
|
||||
#
|
||||
# In this example, if the configuration is set to `:never`, the job will
|
||||
# be enqueued immediately, even though the `Topic` hasn't been committed yet.
|
||||
# Because of this, if the job is picked up almost immediately, or if the
|
||||
# transaction doesn't succeed for some reason, the job will fail to find this
|
||||
# topic in the database.
|
||||
#
|
||||
# If `enqueue_after_transaction_commit` is set to `:default`, the queue adapter
|
||||
# will define the behaviour.
|
||||
#
|
||||
# Note: Active Job backends can disable this feature. This is generally done by
|
||||
# backends that use the same database as Active Record as a queue, hence they
|
||||
# don't need this feature.
|
||||
#++
|
||||
# Rails.application.config.active_job.enqueue_after_transaction_commit = :default
|
||||
|
||||
###
|
||||
# Adds image/webp to the list of content types Active Storage considers as an image
|
||||
# Prevents automatic conversion to a fallback PNG, and assumes clients support WebP, as they support gif, jpeg, and png.
|
||||
# This is possible due to broad browser support for WebP, but older browsers and email clients may still not support
|
||||
# WebP. Requires imagemagick/libvips built with WebP support.
|
||||
#++
|
||||
# Rails.application.config.active_storage.web_image_content_types = %w[image/png image/jpeg image/gif image/webp]
|
||||
|
||||
###
|
||||
# Enable validation of migration timestamps. When set, an ActiveRecord::InvalidMigrationTimestampError
|
||||
# will be raised if the timestamp prefix for a migration is more than a day ahead of the timestamp
|
||||
# associated with the current time. This is done to prevent forward-dating of migration files, which can
|
||||
# impact migration generation and other migration commands.
|
||||
#
|
||||
# Applications with existing timestamped migrations that do not adhere to the
|
||||
# expected format can disable validation by setting this config to `false`.
|
||||
#++
|
||||
# Rails.application.config.active_record.validate_migration_timestamps = true
|
||||
|
||||
###
|
||||
# Controls whether the PostgresqlAdapter should decode dates automatically with manual queries.
|
||||
#
|
||||
# Example:
|
||||
# ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.select_value("select '2024-01-01'::date") #=> Date
|
||||
#
|
||||
# This query used to return a `String`.
|
||||
#++
|
||||
# Rails.application.config.active_record.postgresql_adapter_decode_dates = true
|
||||
|
||||
###
|
||||
# Enables YJIT as of Ruby 3.3, to bring sizeable performance improvements. If you are
|
||||
# deploying to a memory constrained environment you may want to set this to `false`.
|
||||
#++
|
||||
# Rails.application.config.yjit = true
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
config = Postal::Config.oidc
|
||||
if config.enabled?
|
||||
client_options = { identifier: config.identifier, secret: config.secret }
|
||||
|
||||
client_options[:redirect_uri] = "#{Postal::Config.postal.web_protocol}://#{Postal::Config.postal.web_hostname}/auth/oidc/callback"
|
||||
|
||||
unless config.discovery?
|
||||
client_options[:authorization_endpoint] = config.authorization_endpoint
|
||||
client_options[:token_endpoint] = config.token_endpoint
|
||||
client_options[:userinfo_endpoint] = config.userinfo_endpoint
|
||||
client_options[:jwks_uri] = config.jwks_uri
|
||||
end
|
||||
|
||||
Rails.application.config.middleware.use OmniAuth::Builder do
|
||||
provider :openid_connect, name: :oidc,
|
||||
scope: config.scopes.map(&:to_sym),
|
||||
uid_field: config.uid_field,
|
||||
issuer: config.issuer,
|
||||
discovery: config.discovery?,
|
||||
client_options: client_options
|
||||
end
|
||||
|
||||
OmniAuth.config.on_failure = proc do |env|
|
||||
SessionsController.action(:oauth_failure).call(env)
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
if Postal::Config.rails.secret_key
|
||||
Rails.application.secrets.secret_key_base = Postal::Config.rails.secret_key
|
||||
Rails.application.credentials.secret_key_base = Postal::Config.rails.secret_key
|
||||
else
|
||||
warn "No secret key was specified in the Postal config file. Using one for just this session"
|
||||
Rails.application.secrets.secret_key_base = SecureRandom.hex(128)
|
||||
Rails.application.credentials.secret_key_base = SecureRandom.hex(128)
|
||||
end
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Rails.application.routes.draw do
|
||||
# Legacy API Routes
|
||||
match "/api/v1/send/message" => "legacy_api/send#message", via: [:get, :post, :patch, :put]
|
||||
match "/api/v1/send/raw" => "legacy_api/send#raw", via: [:get, :post, :patch, :put]
|
||||
match "/api/v1/messages/message" => "legacy_api/messages#message", via: [:get, :post, :patch, :put]
|
||||
match "/api/v1/messages/deliveries" => "legacy_api/messages#deliveries", via: [:get, :post, :patch, :put]
|
||||
|
||||
scope "org/:org_permalink", as: "organization" do
|
||||
resources :domains, only: [:index, :new, :create, :destroy] do
|
||||
match :verify, on: :member, via: [:get, :post]
|
||||
@@ -85,6 +91,12 @@ Rails.application.routes.draw do
|
||||
match "login/reset" => "sessions#begin_password_reset", :via => [:get, :post]
|
||||
match "login/reset/:token" => "sessions#finish_password_reset", :via => [:get, :post]
|
||||
|
||||
if Postal::Config.oidc.enabled?
|
||||
get "auth/oidc/callback", to: "sessions#create_from_oidc"
|
||||
end
|
||||
|
||||
get ".well-known/jwks.json" => "well_known#jwks"
|
||||
|
||||
get "ip" => "sessions#ip"
|
||||
|
||||
root "organizations#index"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddOIDCFieldsToUser < ActiveRecord::Migration[7.0]
|
||||
|
||||
def change
|
||||
add_column :users, :oidc_uid, :string
|
||||
add_column :users, :oidc_issuer, :string
|
||||
end
|
||||
|
||||
end
|
||||
+3
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2024_02_23_141501) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2024_03_11_205229) do
|
||||
create_table "additional_route_endpoints", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
|
||||
t.integer "route_id"
|
||||
t.string "endpoint_type"
|
||||
@@ -330,6 +330,8 @@ ActiveRecord::Schema[7.0].define(version: 2024_02_23_141501) do
|
||||
t.string "password_reset_token"
|
||||
t.datetime "password_reset_token_valid_until", precision: nil
|
||||
t.boolean "admin", default: false
|
||||
t.string "oidc_uid"
|
||||
t.string "oidc_issuer"
|
||||
t.index ["email_address"], name: "index_users_on_email_address", length: 8
|
||||
t.index ["uuid"], name: "index_users_on_uuid", length: 8
|
||||
end
|
||||
|
||||
@@ -15,14 +15,18 @@ This document contains all the environment variables which are available for thi
|
||||
| `POSTAL_DEFAULT_SPAM_FAILURE_THRESHOLD` | Integer | The default threshold at which a message should be treated as spam failure | 20 |
|
||||
| `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 |
|
||||
| `POSTAL_USE_RESENT_SENDER_HEADER` | Boolean | Append a Resend-Sender header to all outgoing e-mails | true |
|
||||
| `POSTAL_SIGNING_KEY_PATH` | String | Path to the private key used for signing | config/postal/signing.key |
|
||||
| `POSTAL_SMTP_RELAYS` | Array of strings | An array of SMTP relays in the format of smtp://host:port | |
|
||||
| `POSTAL_TRUSTED_PROXIES` | Array of strings | An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses) | |
|
||||
| `POSTAL_SIGNING_KEY_PATH` | String | Path to the private key used for signing | $config-file-root/signing.key |
|
||||
| `POSTAL_SMTP_RELAYS` | Array of strings | An array of SMTP relays in the format of smtp://host:port | [] |
|
||||
| `POSTAL_TRUSTED_PROXIES` | Array of strings | An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses) | [] |
|
||||
| `POSTAL_ALLOWED_REQUEST_DESTINATIONS` | Array of strings | Hostnames or IP/CIDR ranges that outbound webhook and HTTP endpoint requests are permitted to reach even when they resolve to a private, loopback, link-local or otherwise reserved address. All other such destinations are blocked to prevent SSRF. | [] |
|
||||
| `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 |
|
||||
| `POSTAL_BATCH_QUEUED_MESSAGES` | Boolean | When enabled queued messages will be de-queued in batches based on their destination | true |
|
||||
| `WEB_SERVER_DEFAULT_PORT` | Integer | The default port the web server should listen on unless overriden by the PORT environment variable | 5000 |
|
||||
| `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 |
|
||||
| `WEB_SERVER_MAX_THREADS` | Integer | The maximum number of threads which can be used by the web server | 5 |
|
||||
| `WORKER_DEFAULT_HEALTH_SERVER_PORT` | Integer | The default port for the worker health server to listen on | 9090 |
|
||||
| `WORKER_DEFAULT_HEALTH_SERVER_BIND_ADDRESS` | String | The default bind address for the worker health server to listen on | 127.0.0.1 |
|
||||
| `WORKER_THREADS` | Integer | The number of threads to execute within each worker | 2 |
|
||||
| `MAIN_DB_HOST` | String | Hostname for the main MariaDB server | localhost |
|
||||
| `MAIN_DB_PORT` | Integer | The MariaDB port to connect to | 3306 |
|
||||
| `MAIN_DB_USERNAME` | String | The MariaDB username | postal |
|
||||
@@ -39,20 +43,21 @@ This document contains all the environment variables which are available for thi
|
||||
| `LOGGING_RAILS_LOG_ENABLED` | Boolean | Enable the default Rails logger | false |
|
||||
| `LOGGING_SENTRY_DSN` | String | A DSN which should be used to report exceptions to Sentry | |
|
||||
| `LOGGING_ENABLED` | Boolean | Enable the Postal logger to log to STDOUT | true |
|
||||
| `LOGGING_LEVEL` | String | The minimum log level for the Postal logger (debug, info, warn, error, fatal) | INFO |
|
||||
| `LOGGING_HIGHLIGHTING_ENABLED` | Boolean | Enable highlighting of log lines | false |
|
||||
| `GELF_HOST` | String | GELF-capable host to send logs to | |
|
||||
| `GELF_PORT` | Integer | GELF port to send logs to | 12201 |
|
||||
| `GELF_FACILITY` | String | The facility name to add to all log entries sent to GELF | postal |
|
||||
| `SMTP_SERVER_DEFAULT_PORT` | Integer | The default port the SMTP server should listen on unless overriden by the PORT environment variable | 25 |
|
||||
| `SMTP_SERVER_DEFAULT_BIND_ADDRESS` | String | The default bind address the SMTP server should listen on unless overriden by the BIND_ADDRESS environment variable | 127.0.0.1 |
|
||||
| `SMTP_SERVER_DEFAULT_BIND_ADDRESS` | String | The default bind address the SMTP server should listen on unless overriden by the BIND_ADDRESS environment variable | :: |
|
||||
| `SMTP_SERVER_DEFAULT_HEALTH_SERVER_PORT` | Integer | The default port for the SMTP server health server to listen on | 9091 |
|
||||
| `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 |
|
||||
| `SMTP_SERVER_TLS_ENABLED` | Boolean | Enable TLS for the SMTP server (requires certificate) | false |
|
||||
| `SMTP_SERVER_TLS_CERTIFICATE_PATH` | String | The path to the SMTP server's TLS certificate | config/postal/smtp.cert |
|
||||
| `SMTP_SERVER_TLS_PRIVATE_KEY_PATH` | String | The path to the SMTP server's TLS private key | config/postal/smtp.key |
|
||||
| `SMTP_SERVER_TLS_CERTIFICATE_PATH` | String | The path to the SMTP server's TLS certificate | $config-file-root/smtp.cert |
|
||||
| `SMTP_SERVER_TLS_PRIVATE_KEY_PATH` | String | The path to the SMTP server's TLS private key | $config-file-root/smtp.key |
|
||||
| `SMTP_SERVER_TLS_CIPHERS` | String | Override ciphers to use for SSL | |
|
||||
| `SMTP_SERVER_SSL_VERSION` | String | The SSL versions which are supported | SSLv23 |
|
||||
| `SMTP_SERVER_PROXY_PROTOCOL` | Boolean | Enable proxy protocol for use behind some load balancers | false |
|
||||
| `SMTP_SERVER_PROXY_PROTOCOL` | Boolean | Enable proxy protocol for use behind some load balancers (supports proxy protocol v1 only) | false |
|
||||
| `SMTP_SERVER_LOG_CONNECTIONS` | Boolean | Enable connection logging | false |
|
||||
| `SMTP_SERVER_MAX_MESSAGE_SIZE` | Integer | The maximum message size to accept from the SMTP server (in MB) | 14 |
|
||||
| `SMTP_SERVER_LOG_IP_ADDRESS_EXCLUSION_MATCHER` | String | A regular expression to use to exclude connections from logging | |
|
||||
@@ -96,3 +101,18 @@ This document contains all the environment variables which are available for thi
|
||||
| `MIGRATION_WAITER_ENABLED` | Boolean | Wait for all migrations to run before starting a process | false |
|
||||
| `MIGRATION_WAITER_ATTEMPTS` | Integer | The number of attempts to try waiting for migrations to complete before start | 120 |
|
||||
| `MIGRATION_WAITER_SLEEP_TIME` | Integer | The number of seconds to wait between each migration check | 2 |
|
||||
| `OIDC_ENABLED` | Boolean | Enable OIDC authentication | false |
|
||||
| `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 |
|
||||
| `OIDC_NAME` | String | The name of the OIDC provider as shown in the UI | OIDC Provider |
|
||||
| `OIDC_ISSUER` | String | The OIDC issuer URL | |
|
||||
| `OIDC_IDENTIFIER` | String | The client ID for OIDC | |
|
||||
| `OIDC_SECRET` | String | The client secret for OIDC | |
|
||||
| `OIDC_SCOPES` | Array of strings | Scopes to request from the OIDC server. | ["openid", "email"] |
|
||||
| `OIDC_UID_FIELD` | String | The field to use to determine the user's UID | sub |
|
||||
| `OIDC_EMAIL_ADDRESS_FIELD` | String | The field to use to determine the user's email address | email |
|
||||
| `OIDC_NAME_FIELD` | String | The field to use to determine the user's name | name |
|
||||
| `OIDC_DISCOVERY` | Boolean | Enable discovery to determine endpoints from .well-known/openid-configuration from the Issuer | true |
|
||||
| `OIDC_AUTHORIZATION_ENDPOINT` | String | The authorize endpoint on the authorization server (only used when discovery is false) | |
|
||||
| `OIDC_TOKEN_ENDPOINT` | String | The token endpoint on the authorization server (only used when discovery is false) | |
|
||||
| `OIDC_USERINFO_ENDPOINT` | String | The user info endpoint on the authorization server (only used when discovery is false) | |
|
||||
| `OIDC_JWKS_URI` | String | The JWKS endpoint on the authorization server (only used when discovery is false) | |
|
||||
|
||||
+49
-5
@@ -24,11 +24,17 @@ postal:
|
||||
# Append a Resend-Sender header to all outgoing e-mails
|
||||
use_resent_sender_header: true
|
||||
# Path to the private key used for signing
|
||||
signing_key_path: config/postal/signing.key
|
||||
signing_key_path: $config-file-root/signing.key
|
||||
# An array of SMTP relays in the format of smtp://host:port
|
||||
smtp_relays: []
|
||||
# An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses)
|
||||
trusted_proxies: []
|
||||
# Hostnames or IP/CIDR ranges that outbound webhook and HTTP endpoint requests are permitted to reach even when they resolve to a private, loopback, link-local or otherwise reserved address. All other such destinations are blocked to prevent SSRF.
|
||||
allowed_request_destinations: []
|
||||
# The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried.
|
||||
queued_message_lock_stale_days: 1
|
||||
# When enabled queued messages will be de-queued in batches based on their destination
|
||||
batch_queued_messages: true
|
||||
|
||||
web_server:
|
||||
# The default port the web server should listen on unless overriden by the PORT environment variable
|
||||
@@ -43,6 +49,8 @@ worker:
|
||||
default_health_server_port: 9090
|
||||
# The default bind address for the worker health server to listen on
|
||||
default_health_server_bind_address: 127.0.0.1
|
||||
# The number of threads to execute within each worker
|
||||
threads: 2
|
||||
|
||||
main_db:
|
||||
# Hostname for the main MariaDB server
|
||||
@@ -81,6 +89,8 @@ logging:
|
||||
sentry_dsn:
|
||||
# Enable the Postal logger to log to STDOUT
|
||||
enabled: true
|
||||
# The minimum log level for the Postal logger (debug, info, warn, error, fatal)
|
||||
level: INFO
|
||||
# Enable highlighting of log lines
|
||||
highlighting_enabled: false
|
||||
|
||||
@@ -96,7 +106,7 @@ smtp_server:
|
||||
# The default port the SMTP server should listen on unless overriden by the PORT environment variable
|
||||
default_port: 25
|
||||
# The default bind address the SMTP server should listen on unless overriden by the BIND_ADDRESS environment variable
|
||||
default_bind_address: 127.0.0.1
|
||||
default_bind_address: ::
|
||||
# The default port for the SMTP server health server to listen on
|
||||
default_health_server_port: 9091
|
||||
# The default bind address for the SMTP server health server to listen on
|
||||
@@ -104,14 +114,14 @@ smtp_server:
|
||||
# Enable TLS for the SMTP server (requires certificate)
|
||||
tls_enabled: false
|
||||
# The path to the SMTP server's TLS certificate
|
||||
tls_certificate_path: config/postal/smtp.cert
|
||||
tls_certificate_path: $config-file-root/smtp.cert
|
||||
# The path to the SMTP server's TLS private key
|
||||
tls_private_key_path: config/postal/smtp.key
|
||||
tls_private_key_path: $config-file-root/smtp.key
|
||||
# Override ciphers to use for SSL
|
||||
tls_ciphers:
|
||||
# The SSL versions which are supported
|
||||
ssl_version: SSLv23
|
||||
# Enable proxy protocol for use behind some load balancers
|
||||
# Enable proxy protocol for use behind some load balancers (supports proxy protocol v1 only)
|
||||
proxy_protocol: false
|
||||
# Enable connection logging
|
||||
log_connections: false
|
||||
@@ -217,3 +227,37 @@ migration_waiter:
|
||||
attempts: 120
|
||||
# The number of seconds to wait between each migration check
|
||||
sleep_time: 2
|
||||
|
||||
oidc:
|
||||
# Enable OIDC authentication
|
||||
enabled: false
|
||||
# When enabled, users with passwords will still be able to login locally. If disable, only OpenID Connect will be available.
|
||||
local_authentication_enabled: true
|
||||
# The name of the OIDC provider as shown in the UI
|
||||
name: OIDC Provider
|
||||
# The OIDC issuer URL
|
||||
issuer:
|
||||
# The client ID for OIDC
|
||||
identifier:
|
||||
# The client secret for OIDC
|
||||
secret:
|
||||
# Scopes to request from the OIDC server.
|
||||
scopes:
|
||||
- openid
|
||||
- email
|
||||
# The field to use to determine the user's UID
|
||||
uid_field: sub
|
||||
# The field to use to determine the user's email address
|
||||
email_address_field: email
|
||||
# The field to use to determine the user's name
|
||||
name_field: name
|
||||
# Enable discovery to determine endpoints from .well-known/openid-configuration from the Issuer
|
||||
discovery: true
|
||||
# The authorize endpoint on the authorization server (only used when discovery is false)
|
||||
authorization_endpoint:
|
||||
# The token endpoint on the authorization server (only used when discovery is false)
|
||||
token_endpoint:
|
||||
# The user info endpoint on the authorization server (only used when discovery is false)
|
||||
userinfo_endpoint:
|
||||
# The JWKS endpoint on the authorization server (only used when discovery is false)
|
||||
jwks_uri:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3"
|
||||
services:
|
||||
postal:
|
||||
image: ${POSTAL_IMAGE}
|
||||
|
||||
@@ -15,7 +15,7 @@ class MigrationWaiter
|
||||
def wait
|
||||
attempts_remaining = ATTEMPTS
|
||||
loop do
|
||||
pending_migrations = ActiveRecord::Base.connection.migration_context.open.pending_migrations.size
|
||||
pending_migrations = pending_migration_count
|
||||
if pending_migrations.zero?
|
||||
Postal.logger.info "no pending migrations, continuing"
|
||||
return
|
||||
@@ -33,6 +33,12 @@ class MigrationWaiter
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the number of migrations which exist in the application but have
|
||||
# not yet been applied to the database.
|
||||
def pending_migration_count
|
||||
ActiveRecord::Base.connection_pool.migration_context.open.pending_migrations.size
|
||||
end
|
||||
|
||||
def wait_if_appropriate
|
||||
# Don't wait if not configured
|
||||
return unless Postal::Config.migration_waiter.enabled?
|
||||
|
||||
+74
-14
@@ -11,16 +11,19 @@ require "konfig/sources/environment"
|
||||
require "konfig/sources/yaml"
|
||||
require "dotenv"
|
||||
require "klogger"
|
||||
require "securerandom"
|
||||
|
||||
require_relative "error"
|
||||
require_relative "version"
|
||||
require_relative "config_schema"
|
||||
require_relative "legacy_config_source"
|
||||
require_relative "signer"
|
||||
|
||||
module Postal
|
||||
|
||||
class << self
|
||||
|
||||
attr_writer :current_process_type
|
||||
|
||||
# Return the path to the config file
|
||||
#
|
||||
# @return [String]
|
||||
@@ -37,30 +40,36 @@ module Postal
|
||||
Dotenv.load(".env")
|
||||
sources << Konfig::Sources::Environment.new(ENV)
|
||||
|
||||
silence_config_messages = ENV.fetch("SILENCE_POSTAL_CONFIG_MESSAGES", "false") == "true"
|
||||
|
||||
# If a config file exists, we need to load that. Config files can
|
||||
# either be legacy (v1) or new (v2). Any file without a 'version'
|
||||
# key is a legacy file whereas new-style config files will include
|
||||
# the 'version: 2' key/value.
|
||||
if File.file?(config_file_path)
|
||||
puts "Loading config from #{config_file_path}"
|
||||
unless silence_config_messages
|
||||
warn "Loading config from #{config_file_path}"
|
||||
end
|
||||
|
||||
config_file = File.read(config_file_path)
|
||||
yaml = YAML.safe_load(config_file)
|
||||
config_version = yaml["version"] || 1
|
||||
case config_version
|
||||
when 1
|
||||
puts "WARNING: Using legacy config file format. Upgrade your postal.yml to use"
|
||||
puts "version 2 of the Postal configuration or configure using environment"
|
||||
puts "variables. See https://postalserver.io/config-v2 for details."
|
||||
unless silence_config_messages
|
||||
warn "WARNING: Using legacy config file format. Upgrade your postal.yml to use"
|
||||
warn "version 2 of the Postal configuration or configure using environment"
|
||||
warn "variables. See https://docs.postalserver.io/config-v2 for details."
|
||||
end
|
||||
sources << LegacyConfigSource.new(yaml)
|
||||
when 2
|
||||
sources << Konfig::Sources::YAML.new(config_file)
|
||||
else
|
||||
raise "Invalid version specified in Postal config file. Must be 1 or 2."
|
||||
end
|
||||
else
|
||||
puts "No configuration file found at #{config_file_path}"
|
||||
puts "Only using environment variables for configuration"
|
||||
elsif !silence_config_messages
|
||||
warn "No configuration file found at #{config_file_path}"
|
||||
warn "Only using environment variables for configuration"
|
||||
end
|
||||
|
||||
# Build configuration with the provided sources.
|
||||
@@ -74,16 +83,21 @@ module Postal
|
||||
def logger
|
||||
@logger ||= begin
|
||||
k = Klogger.new(nil, destination: Config.logging.enabled? ? $stdout : "/dev/null", highlight: Config.logging.highlighting_enabled?)
|
||||
k.level = Config.logging.level
|
||||
k.add_destination(graylog_logging_destination) if Config.gelf.host.present?
|
||||
k
|
||||
end
|
||||
end
|
||||
|
||||
def process_identity
|
||||
@process_identity ||= SecureRandom.hex(8)
|
||||
end
|
||||
|
||||
def process_name
|
||||
@process_name ||= begin
|
||||
"host:#{Socket.gethostname} pid:#{Process.pid}"
|
||||
"host:#{Socket.gethostname} pid:#{Process.pid} id:#{process_identity}"
|
||||
rescue StandardError
|
||||
"pid:#{Process.pid}"
|
||||
"pid:#{Process.pid} id:#{process_identity}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -98,12 +112,15 @@ module Postal
|
||||
"#{locker_name} #{suffix}"
|
||||
end
|
||||
|
||||
def signing_key
|
||||
@signing_key ||= OpenSSL::PKey::RSA.new(File.read(Config.postal.signing_key_path))
|
||||
def signer
|
||||
@signer ||= begin
|
||||
key = OpenSSL::PKey::RSA.new(File.read(Config.postal.signing_key_path))
|
||||
Signer.new(key)
|
||||
end
|
||||
end
|
||||
|
||||
def rp_dkim_dns_record
|
||||
public_key = signing_key.public_key.to_s.gsub(/-+[A-Z ]+-+\n/, "").gsub(/\n/, "")
|
||||
public_key = signer.private_key.public_key.to_s.gsub(/-+[A-Z ]+-+\n/, "").gsub(/\n/, "")
|
||||
"v=DKIM1; t=s; h=sha256; p=#{public_key};"
|
||||
end
|
||||
|
||||
@@ -119,13 +136,56 @@ module Postal
|
||||
notifier.notify!(short_message: short_message, **{
|
||||
facility: Config.gelf.facility,
|
||||
_environment: Config.rails.environment,
|
||||
_version: Postal::VERSION.to_s,
|
||||
_version: Postal.version.to_s,
|
||||
_group_ids: group_ids.join(" ")
|
||||
}.merge(payload.transform_keys { |k| "_#{k}".to_sym }.transform_values(&:to_s)))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Change the connection pool size to the given size.
|
||||
#
|
||||
# @param new_size [Integer]
|
||||
# @return [void]
|
||||
def change_database_connection_pool_size(new_size)
|
||||
ActiveRecord::Base.connection_pool.disconnect!
|
||||
|
||||
config = ActiveRecord::Base.configurations
|
||||
.configs_for(env_name: Config.rails.environment)
|
||||
.first
|
||||
.configuration_hash
|
||||
|
||||
ActiveRecord::Base.establish_connection(config.merge(pool: new_size))
|
||||
end
|
||||
|
||||
# Return the branch name which created this release
|
||||
#
|
||||
# @return [String, nil]
|
||||
def branch
|
||||
return @branch if instance_variable_defined?("@branch")
|
||||
|
||||
@branch ||= read_version_file("BRANCH")
|
||||
end
|
||||
|
||||
# Return the version
|
||||
#
|
||||
# @return [String, nil]
|
||||
def version
|
||||
return @version if instance_variable_defined?("@version")
|
||||
|
||||
@version ||= read_version_file("VERSION") || "0.0.0"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def read_version_file(file)
|
||||
path = File.expand_path("../../../" + file, __FILE__)
|
||||
return unless File.exist?(path)
|
||||
|
||||
value = File.read(path).strip
|
||||
value.empty? ? nil : value
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
Config = initialize_config
|
||||
|
||||
+108
-1
@@ -91,6 +91,24 @@ module Postal
|
||||
description "An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses)"
|
||||
transform { |ip| IPAddr.new(ip) }
|
||||
end
|
||||
|
||||
string :allowed_request_destinations do
|
||||
array
|
||||
description "Hostnames or IP/CIDR ranges that outbound webhook and HTTP " \
|
||||
"endpoint requests are permitted to reach even when they resolve " \
|
||||
"to a private, loopback, link-local or otherwise reserved address. " \
|
||||
"All other such destinations are blocked to prevent SSRF."
|
||||
end
|
||||
|
||||
integer :queued_message_lock_stale_days do
|
||||
description "The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried."
|
||||
default 1
|
||||
end
|
||||
|
||||
boolean :batch_queued_messages do
|
||||
description "When enabled queued messages will be de-queued in batches based on their destination"
|
||||
default true
|
||||
end
|
||||
end
|
||||
|
||||
group :web_server do
|
||||
@@ -120,6 +138,11 @@ module Postal
|
||||
description "The default bind address for the worker health server to listen on"
|
||||
default "127.0.0.1"
|
||||
end
|
||||
|
||||
integer :threads do
|
||||
description "The number of threads to execute within each worker"
|
||||
default 2
|
||||
end
|
||||
end
|
||||
|
||||
group :main_db do
|
||||
@@ -204,6 +227,19 @@ module Postal
|
||||
default true
|
||||
end
|
||||
|
||||
string :level do
|
||||
description "The minimum log level for the Postal logger (debug, info, warn, error, fatal)"
|
||||
default "INFO"
|
||||
transform do |value|
|
||||
normalized = value.to_s.strip.downcase
|
||||
normalized = "info" if normalized.empty?
|
||||
unless %w[debug info warn error fatal].include?(normalized)
|
||||
raise ArgumentError, "logging.level must be one of: debug, info, warn, error, fatal (got #{value.inspect})"
|
||||
end
|
||||
normalized
|
||||
end
|
||||
end
|
||||
|
||||
boolean :highlighting_enabled do
|
||||
description "Enable highlighting of log lines"
|
||||
default false
|
||||
@@ -274,7 +310,7 @@ module Postal
|
||||
end
|
||||
|
||||
boolean :proxy_protocol do
|
||||
description "Enable proxy protocol for use behind some load balancers"
|
||||
description "Enable proxy protocol for use behind some load balancers (supports proxy protocol v1 only)"
|
||||
default false
|
||||
end
|
||||
|
||||
@@ -503,6 +539,77 @@ module Postal
|
||||
default 2
|
||||
end
|
||||
end
|
||||
|
||||
group :oidc do
|
||||
boolean :enabled do
|
||||
description "Enable OIDC authentication"
|
||||
default false
|
||||
end
|
||||
|
||||
boolean :local_authentication_enabled do
|
||||
description "When enabled, users with passwords will still be able to login locally. If disable, only OpenID Connect will be available."
|
||||
default true
|
||||
end
|
||||
|
||||
string :name do
|
||||
description "The name of the OIDC provider as shown in the UI"
|
||||
default "OIDC Provider"
|
||||
end
|
||||
|
||||
string :issuer do
|
||||
description "The OIDC issuer URL"
|
||||
end
|
||||
|
||||
string :identifier do
|
||||
description "The client ID for OIDC"
|
||||
end
|
||||
|
||||
string :secret do
|
||||
description "The client secret for OIDC"
|
||||
end
|
||||
|
||||
string :scopes do
|
||||
description "Scopes to request from the OIDC server."
|
||||
array
|
||||
default ["openid", "email"]
|
||||
end
|
||||
|
||||
string :uid_field do
|
||||
description "The field to use to determine the user's UID"
|
||||
default "sub"
|
||||
end
|
||||
|
||||
string :email_address_field do
|
||||
description "The field to use to determine the user's email address"
|
||||
default "email"
|
||||
end
|
||||
|
||||
string :name_field do
|
||||
description "The field to use to determine the user's name"
|
||||
default "name"
|
||||
end
|
||||
|
||||
boolean :discovery do
|
||||
description "Enable discovery to determine endpoints from .well-known/openid-configuration from the Issuer"
|
||||
default true
|
||||
end
|
||||
|
||||
string :authorization_endpoint do
|
||||
description "The authorize endpoint on the authorization server (only used when discovery is false)"
|
||||
end
|
||||
|
||||
string :token_endpoint do
|
||||
description "The token endpoint on the authorization server (only used when discovery is false)"
|
||||
end
|
||||
|
||||
string :userinfo_endpoint do
|
||||
description "The user info endpoint on the authorization server (only used when discovery is false)"
|
||||
end
|
||||
|
||||
string :jwks_uri do
|
||||
description "The JWKS endpoint on the authorization server (only used when discovery is false)"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
|
||||
+27
-13
@@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "net/https"
|
||||
require "resolv"
|
||||
require "uri"
|
||||
|
||||
module Postal
|
||||
@@ -40,25 +41,31 @@ module Postal
|
||||
end
|
||||
|
||||
if options[:sign]
|
||||
signature = EncryptoSigno.sign(Postal.signing_key, request.body.to_s).gsub("\n", "")
|
||||
request.add_field "X-Postal-Signature", signature
|
||||
request.add_field "X-Postal-Signature-KID", Postal.signer.jwk.kid
|
||||
request.add_field "X-Postal-Signature", Postal.signer.sha1_sign64(request.body.to_s)
|
||||
request.add_field "X-Postal-Signature-256", Postal.signer.sign64(request.body.to_s)
|
||||
end
|
||||
|
||||
request["User-Agent"] = options[:user_agent] || "Postal/#{Postal.version}"
|
||||
|
||||
connection = Net::HTTP.new(uri.host, uri.port)
|
||||
|
||||
if uri.scheme == "https"
|
||||
connection.use_ssl = true
|
||||
connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
||||
ssl = true
|
||||
else
|
||||
ssl = false
|
||||
end
|
||||
timeout = options[:timeout] || 60
|
||||
ssl = uri.scheme == "https"
|
||||
|
||||
begin
|
||||
timeout = options[:timeout] || 60
|
||||
Timeout.timeout(timeout) do
|
||||
connect_address = AddressGuard.safe_connect_address(uri.host)
|
||||
|
||||
connection = Net::HTTP.new(uri.host, uri.port)
|
||||
# Pin the connection to the address we validated above so that the socket
|
||||
# cannot be redirected to a different (e.g. internal) address via a DNS
|
||||
# rebinding race between the check and the connection.
|
||||
connection.ipaddr = connect_address
|
||||
|
||||
if uri.scheme == "https"
|
||||
connection.use_ssl = true
|
||||
connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
||||
end
|
||||
|
||||
result = connection.request(request)
|
||||
{
|
||||
code: result.code.to_i,
|
||||
@@ -67,6 +74,13 @@ module Postal
|
||||
secure: ssl
|
||||
}
|
||||
end
|
||||
rescue BlockedDestinationError => e
|
||||
{
|
||||
code: -4,
|
||||
body: e.message,
|
||||
headers: {},
|
||||
secure: ssl
|
||||
}
|
||||
rescue OpenSSL::SSL::SSLError
|
||||
{
|
||||
code: -3,
|
||||
@@ -74,7 +88,7 @@ module Postal
|
||||
headers: {},
|
||||
secure: ssl
|
||||
}
|
||||
rescue SocketError, Errno::ECONNRESET, EOFError, Errno::EINVAL, Errno::ENETUNREACH, Errno::EHOSTUNREACH, Errno::ECONNREFUSED => e
|
||||
rescue Resolv::ResolvError, SocketError, SystemCallError, EOFError => e
|
||||
{
|
||||
code: -2,
|
||||
body: e.message,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "ipaddr"
|
||||
require "resolv"
|
||||
require "socket"
|
||||
|
||||
module Postal
|
||||
module HTTP
|
||||
# Guards outbound HTTP requests against SSRF by resolving the destination
|
||||
# host and refusing to connect to private, loopback, link-local, multicast
|
||||
# or otherwise reserved addresses (for example cloud metadata endpoints).
|
||||
#
|
||||
# Administrators can permit specific destinations by adding hostnames or
|
||||
# IP/CIDR ranges to the `postal.allowed_request_destinations` config option.
|
||||
class AddressGuard
|
||||
|
||||
# IP ranges that outbound requests are never allowed to reach unless the
|
||||
# destination has been explicitly allowlisted.
|
||||
BLOCKED_RANGES = [
|
||||
# IPv4
|
||||
"0.0.0.0/8", # "this host on this network"
|
||||
"10.0.0.0/8", # RFC1918 private
|
||||
"100.64.0.0/10", # RFC6598 carrier-grade NAT
|
||||
"127.0.0.0/8", # loopback
|
||||
"169.254.0.0/16", # link-local (incl. 169.254.169.254 metadata)
|
||||
"172.16.0.0/12", # RFC1918 private
|
||||
"192.0.0.0/24", # IETF protocol assignments
|
||||
"192.168.0.0/16", # RFC1918 private
|
||||
"198.18.0.0/15", # benchmarking
|
||||
"224.0.0.0/4", # multicast
|
||||
"240.0.0.0/4", # reserved
|
||||
# IPv6
|
||||
"::/128", # unspecified
|
||||
"::1/128", # loopback
|
||||
"::ffff:0:0/96", # IPv4-mapped (also re-checked against the v4 list)
|
||||
"fc00::/7", # unique-local
|
||||
"fe80::/10", # link-local
|
||||
"ff00::/8", # multicast
|
||||
].map { |range| IPAddr.new(range) }.freeze
|
||||
|
||||
class << self
|
||||
|
||||
# Resolve and validate the given host, returning the IP address the
|
||||
# connection should be pinned to (as a string). Pinning the connection
|
||||
# to the validated address prevents a DNS-rebinding race between the
|
||||
# check here and the actual connection.
|
||||
#
|
||||
# @param [String] host the hostname or IP literal from the request URL
|
||||
# @raise [Postal::HTTP::BlockedDestinationError] if the host cannot be
|
||||
# resolved or any resolved address is not permitted
|
||||
# @raise [SocketError] if the host only resolves to addresses whose
|
||||
# family this server cannot reach (e.g. IPv6 with no IPv6 support)
|
||||
# @return [String] the validated IP address to connect to
|
||||
def safe_connect_address(host)
|
||||
new(host).safe_connect_address
|
||||
end
|
||||
|
||||
# Whether this server has IPv6 connectivity (a global IPv6 address on
|
||||
# one of its interfaces). Memoized as it does not change at runtime.
|
||||
def ipv6_supported?
|
||||
return @ipv6_supported unless @ipv6_supported.nil?
|
||||
|
||||
@ipv6_supported = local_families.include?(:ipv6)
|
||||
end
|
||||
|
||||
# Whether this server has IPv4 connectivity. Defaults to true unless the
|
||||
# host clearly only has IPv6, so that a host reporting no global
|
||||
# addresses at all (e.g. inside a minimal container) still attempts IPv4
|
||||
# as it did before this guard existed.
|
||||
def ipv4_supported?
|
||||
return @ipv4_supported unless @ipv4_supported.nil?
|
||||
|
||||
families = local_families
|
||||
@ipv4_supported = families.include?(:ipv4) || !families.include?(:ipv6)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def local_families
|
||||
families = []
|
||||
Socket.ip_address_list.each do |address|
|
||||
families << :ipv4 if address.ipv4? && !address.ipv4_loopback?
|
||||
families << :ipv6 if address.ipv6? && !address.ipv6_loopback? && !address.ipv6_linklocal?
|
||||
end
|
||||
families.uniq
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# @param [String] host
|
||||
def initialize(host)
|
||||
@host = host.to_s
|
||||
end
|
||||
|
||||
def safe_connect_address
|
||||
if @host.empty?
|
||||
raise BlockedDestinationError, "No host was given for the request"
|
||||
end
|
||||
|
||||
addresses = resolve
|
||||
if addresses.empty?
|
||||
raise BlockedDestinationError, "Could not resolve '#{@host}' to any IP address"
|
||||
end
|
||||
|
||||
# Reject the whole request if *any* resolved address is blocked. This is
|
||||
# checked before the reachability filtering below so that a blocked
|
||||
# destination is always reported as such, regardless of which address
|
||||
# families this particular server can reach. It also defeats DNS
|
||||
# responses that mix a public and a private address to slip past.
|
||||
addresses.each do |address|
|
||||
next unless blocked?(address)
|
||||
|
||||
raise BlockedDestinationError,
|
||||
"Destination '#{@host}' (#{address}) is not permitted"
|
||||
end
|
||||
|
||||
# Only connect to an address whose family this server can actually
|
||||
# reach. Otherwise we might pin the connection to an IPv6 address on a
|
||||
# host without IPv6 connectivity and fail to connect even when a usable
|
||||
# IPv4 address was available.
|
||||
usable = addresses.select { |address| family_reachable?(address) }
|
||||
if usable.empty?
|
||||
raise SocketError,
|
||||
"'#{@host}' only resolves to addresses this server cannot reach " \
|
||||
"(#{addresses.join(', ')})"
|
||||
end
|
||||
|
||||
# Prefer IPv4 for predictability; only use IPv6 when it is the only
|
||||
# reachable option.
|
||||
(usable.find(&:ipv4?) || usable.first).to_s
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# @return [Array<IPAddr>]
|
||||
def resolve
|
||||
return [IPAddr.new(@host)] if ip_literal?
|
||||
|
||||
Resolv.getaddresses(@host).filter_map do |address|
|
||||
IPAddr.new(address)
|
||||
rescue IPAddr::InvalidAddressError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def ip_literal?
|
||||
IPAddr.new(@host)
|
||||
true
|
||||
rescue IPAddr::InvalidAddressError
|
||||
false
|
||||
end
|
||||
|
||||
# @param [IPAddr] address
|
||||
def family_reachable?(address)
|
||||
if address.ipv6? && !address.ipv4_mapped?
|
||||
self.class.ipv6_supported?
|
||||
else
|
||||
self.class.ipv4_supported?
|
||||
end
|
||||
end
|
||||
|
||||
# @param [IPAddr] address
|
||||
def blocked?(address)
|
||||
return false if allowlisted?(address)
|
||||
|
||||
# IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) must be checked against the
|
||||
# IPv4 rules using the embedded address, otherwise they bypass the list.
|
||||
if address.ipv6? && address.ipv4_mapped?
|
||||
mapped = address.native
|
||||
return true if mapped.ipv4? && BLOCKED_RANGES.any? { |range| range.include?(mapped) }
|
||||
end
|
||||
|
||||
BLOCKED_RANGES.any? { |range| range.include?(address) }
|
||||
end
|
||||
|
||||
# @param [IPAddr] address
|
||||
def allowlisted?(address)
|
||||
allowlist.any? do |entry|
|
||||
if entry.is_a?(IPAddr)
|
||||
entry.include?(address)
|
||||
else
|
||||
entry.casecmp?(@host)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Allowlist entries are kept as strings in config. An entry that parses as
|
||||
# an IP/CIDR is matched against the resolved address; anything else is
|
||||
# matched against the request hostname (case-insensitively).
|
||||
#
|
||||
# @return [Array<IPAddr, String>]
|
||||
def allowlist
|
||||
@allowlist ||= Array(Postal::Config.postal.allowed_request_destinations).map do |entry|
|
||||
IPAddr.new(entry.to_s)
|
||||
rescue IPAddr::InvalidAddressError
|
||||
entry.to_s
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
module HTTP
|
||||
# Raised when an outbound request would be sent to an address that is not
|
||||
# permitted (a private, loopback, link-local or otherwise reserved address
|
||||
# that has not been explicitly allowlisted). Used as an SSRF guard.
|
||||
class BlockedDestinationError < StandardError
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -70,7 +70,7 @@ module Postal
|
||||
# Return the total size of all stored messages
|
||||
#
|
||||
def total_size
|
||||
query("SELECT SUM(size) AS size FROM `#{database_name}`.`raw_message_sizes`").first["size"] || 0
|
||||
query("SELECT SUM(size) AS size FROM #{escape_identifier(database_name)}.`raw_message_sizes`").first["size"] || 0
|
||||
end
|
||||
|
||||
#
|
||||
@@ -151,11 +151,11 @@ module Postal
|
||||
if options[:count]
|
||||
sql_query << " COUNT(id) AS count"
|
||||
elsif options[:fields]
|
||||
sql_query << (" " + options[:fields].map { |f| "`#{f}`" }.join(", "))
|
||||
sql_query << (" " + options[:fields].map { |f| escape_identifier(f) }.join(", "))
|
||||
else
|
||||
sql_query << " *"
|
||||
end
|
||||
sql_query << " FROM `#{database_name}`.`#{table}`"
|
||||
sql_query << " FROM #{escape_identifier(database_name)}.#{escape_identifier(table)}"
|
||||
if options[:where].present?
|
||||
sql_query << (" " + build_where_string(options[:where], " AND "))
|
||||
end
|
||||
@@ -163,7 +163,7 @@ module Postal
|
||||
direction = (options[:direction] || "ASC").upcase
|
||||
raise Postal::Error, "Invalid direction #{options[:direction]}" unless %w[ASC DESC].include?(direction)
|
||||
|
||||
sql_query << " ORDER BY `#{options[:order]}` #{direction}"
|
||||
sql_query << " ORDER BY #{escape_identifier(options[:order])} #{direction}"
|
||||
end
|
||||
|
||||
if options[:limit]
|
||||
@@ -211,7 +211,7 @@ module Postal
|
||||
# Will return the total number of affected rows.
|
||||
#
|
||||
def update(table, attributes, options = {})
|
||||
sql_query = "UPDATE `#{database_name}`.`#{table}` SET"
|
||||
sql_query = "UPDATE #{escape_identifier(database_name)}.#{escape_identifier(table)} SET"
|
||||
sql_query << " #{hash_to_sql(attributes)}"
|
||||
if options[:where]
|
||||
sql_query << (" " + build_where_string(options[:where]))
|
||||
@@ -227,8 +227,8 @@ module Postal
|
||||
# Will return the ID of the new item.
|
||||
#
|
||||
def insert(table, attributes)
|
||||
sql_query = "INSERT INTO `#{database_name}`.`#{table}`"
|
||||
sql_query << (" (" + attributes.keys.map { |k| "`#{k}`" }.join(", ") + ")")
|
||||
sql_query = "INSERT INTO #{escape_identifier(database_name)}.#{escape_identifier(table)}"
|
||||
sql_query << (" (" + attributes.keys.map { |k| escape_identifier(k) }.join(", ") + ")")
|
||||
sql_query << (" VALUES (" + attributes.values.map { |v| escape(v) }.join(", ") + ")")
|
||||
with_mysql do |mysql|
|
||||
query_on_connection(mysql, sql_query)
|
||||
@@ -243,8 +243,8 @@ module Postal
|
||||
if values.empty?
|
||||
nil
|
||||
else
|
||||
sql_query = "INSERT INTO `#{database_name}`.`#{table}`"
|
||||
sql_query << (" (" + keys.map { |k| "`#{k}`" }.join(", ") + ")")
|
||||
sql_query = "INSERT INTO #{escape_identifier(database_name)}.#{escape_identifier(table)}"
|
||||
sql_query << (" (" + keys.map { |k| escape_identifier(k) }.join(", ") + ")")
|
||||
sql_query << " VALUES "
|
||||
sql_query << values.map { |v| "(" + v.map { |r| escape(r) }.join(", ") + ")" }.join(", ")
|
||||
query(sql_query)
|
||||
@@ -260,7 +260,7 @@ module Postal
|
||||
# Will return the total number of affected rows.
|
||||
#
|
||||
def delete(table, options = {})
|
||||
sql_query = "DELETE FROM `#{database_name}`.`#{table}`"
|
||||
sql_query = "DELETE FROM #{escape_identifier(database_name)}.#{escape_identifier(table)}"
|
||||
sql_query << (" " + build_where_string(options[:where], " AND "))
|
||||
with_mysql do |mysql|
|
||||
query_on_connection(mysql, sql_query)
|
||||
@@ -351,32 +351,41 @@ module Postal
|
||||
|
||||
def hash_to_sql(hash, joiner = ", ")
|
||||
hash.map do |key, value|
|
||||
column = escape_identifier(key)
|
||||
if value.is_a?(Array) && value.all? { |v| v.is_a?(Integer) }
|
||||
"`#{key}` IN (#{value.join(', ')})"
|
||||
"#{column} IN (#{value.join(', ')})"
|
||||
elsif value.is_a?(Array)
|
||||
escaped_values = value.map { |v| escape(v) }.join(", ")
|
||||
"`#{key}` IN (#{escaped_values})"
|
||||
"#{column} IN (#{escaped_values})"
|
||||
elsif value.is_a?(Hash)
|
||||
sql = []
|
||||
value.each do |operator, inner_value|
|
||||
case operator
|
||||
when :less_than
|
||||
sql << "`#{key}` < #{escape(inner_value)}"
|
||||
sql << "#{column} < #{escape(inner_value)}"
|
||||
when :greater_than
|
||||
sql << "`#{key}` > #{escape(inner_value)}"
|
||||
sql << "#{column} > #{escape(inner_value)}"
|
||||
when :less_than_or_equal_to
|
||||
sql << "`#{key}` <= #{escape(inner_value)}"
|
||||
sql << "#{column} <= #{escape(inner_value)}"
|
||||
when :greater_than_or_equal_to
|
||||
sql << "`#{key}` >= #{escape(inner_value)}"
|
||||
sql << "#{column} >= #{escape(inner_value)}"
|
||||
end
|
||||
end
|
||||
sql.empty? ? "1=1" : sql.join(joiner)
|
||||
else
|
||||
"`#{key}` = #{escape(value)}"
|
||||
"#{column} = #{escape(value)}"
|
||||
end
|
||||
end.join(joiner)
|
||||
end
|
||||
|
||||
# Escape a value for safe use as a MySQL identifier (e.g. a column or
|
||||
# table name). Identifiers are wrapped in backticks and any backtick
|
||||
# within the identifier is doubled so it cannot break out of the quoting
|
||||
# and inject arbitrary SQL.
|
||||
def escape_identifier(identifier)
|
||||
"`" + identifier.to_s.gsub("`", "``") + "`"
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "base64"
|
||||
module Postal
|
||||
class Signer
|
||||
|
||||
# Create a new Signer
|
||||
#
|
||||
# @param [OpenSSL::PKey::RSA] private_key The private key to use for signing
|
||||
# @return [Signer]
|
||||
def initialize(private_key)
|
||||
@private_key = private_key
|
||||
end
|
||||
|
||||
# Return the private key
|
||||
#
|
||||
# @return [OpenSSL::PKey::RSA]
|
||||
attr_reader :private_key
|
||||
|
||||
# Return the public key for the private key
|
||||
#
|
||||
# @return [OpenSSL::PKey::RSA]
|
||||
def public_key
|
||||
@private_key.public_key
|
||||
end
|
||||
|
||||
# Sign the given data
|
||||
#
|
||||
# @param [String] data The data to sign
|
||||
# @return [String] The signature
|
||||
def sign(data)
|
||||
private_key.sign(OpenSSL::Digest.new("SHA256"), data)
|
||||
end
|
||||
|
||||
# Sign the given data and return a Base64-encoded signature
|
||||
#
|
||||
# @param [String] data The data to sign
|
||||
# @return [String] The Base64-encoded signature
|
||||
def sign64(data)
|
||||
Base64.strict_encode64(sign(data))
|
||||
end
|
||||
|
||||
# Return a JWK for the private key
|
||||
#
|
||||
# @return [JWT::JWK] The JWK
|
||||
def jwk
|
||||
@jwk ||= JWT::JWK.new(private_key, { use: "sig", alg: "RS256" })
|
||||
end
|
||||
|
||||
# Sign the given data using SHA1 (for legacy use)
|
||||
#
|
||||
# @param [String] data The data to sign
|
||||
# @return [String] The signature
|
||||
def sha1_sign(data)
|
||||
private_key.sign(OpenSSL::Digest.new("SHA1"), data)
|
||||
end
|
||||
|
||||
# Sign the given data using SHA1 (for legacy use) and return a Base64-encoded string
|
||||
#
|
||||
# @param [String] data The data to sign
|
||||
# @return [String] The signature
|
||||
def sha1_sign64(data)
|
||||
Base64.strict_encode64(sha1_sign(data))
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,18 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
|
||||
VERSION_PATH = File.expand_path("../../VERSION", __dir__)
|
||||
if File.file?(VERSION_PATH)
|
||||
VERSION = File.read(VERSION_PATH).strip.delete_prefix("v")
|
||||
else
|
||||
VERSION = "0.0.0-dev"
|
||||
end
|
||||
|
||||
def self.version
|
||||
VERSION
|
||||
end
|
||||
|
||||
Version = VERSION
|
||||
|
||||
end
|
||||
@@ -19,7 +19,7 @@ module Postal
|
||||
contents << " #{name}: []"
|
||||
else
|
||||
contents << " #{name}:"
|
||||
attr.default.each do |d|
|
||||
attr.transform(attr.default).each do |d|
|
||||
contents << " - #{d}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,25 +48,11 @@ class TrackingMiddleware
|
||||
Sentry.capture_exception(e) if defined?(Sentry)
|
||||
end
|
||||
|
||||
source_image = request.params["src"]
|
||||
case source_image
|
||||
when nil
|
||||
if request.params["src"].nil?
|
||||
headers = {}
|
||||
headers["Content-Type"] = "image/png"
|
||||
headers["Content-Length"] = TRACKING_PIXEL.bytesize.to_s
|
||||
[200, headers, [TRACKING_PIXEL]]
|
||||
when /\Ahttps?:\/\//
|
||||
response = Postal::HTTP.get(source_image, timeout: 3)
|
||||
return [404, {}, ["Not found"]] unless response[:code] == 200
|
||||
|
||||
headers = {}
|
||||
headers["Content-Type"] = response[:headers]["content-type"]&.first
|
||||
headers["Last-Modified"] = response[:headers]["last-modified"]&.first
|
||||
headers["Cache-Control"] = response[:headers]["cache-control"]&.first
|
||||
headers["Etag"] = response[:headers]["etag"]&.first
|
||||
headers["Content-Length"] = response[:body].bytesize.to_s
|
||||
[200, headers, [response[:body]]]
|
||||
|
||||
else
|
||||
[400, {}, ["Invalid/missing source image"]]
|
||||
end
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
ENV["SILENCE_POSTAL_CONFIG_LOCATION_MESSAGE"] = "true"
|
||||
require File.expand_path("../lib/postal/config", __dir__)
|
||||
puts Postal.rp_dkim_dns_record
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [ ! -d /tmp/postal-api/.git ];
|
||||
then
|
||||
git clone git@github.com:atech/postal-api /tmp/postal-api
|
||||
else
|
||||
git -C /tmp/postal-api reset --hard HEAD
|
||||
git -C /tmp/postal-api pull origin master
|
||||
fi
|
||||
|
||||
rm -Rf /tmp/postal-api/*
|
||||
|
||||
bundle exec moonrope api /tmp/postal-api
|
||||
|
||||
cd /tmp/postal-api
|
||||
|
||||
git add .
|
||||
git commit -m "update docs"
|
||||
git push origin master
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
require File.expand_path("../lib/postal/version", __dir__)
|
||||
require File.expand_path("../lib/postal/config", __dir__)
|
||||
puts Postal.version
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Legacy Messages API", type: :request do
|
||||
describe "/api/v1/messages/deliveries" do
|
||||
context "when no authentication is provided" do
|
||||
it "returns an error" do
|
||||
post "/api/v1/messages/deliveries"
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "AccessDenied"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the credential does not match anything" do
|
||||
it "returns an error" do
|
||||
post "/api/v1/messages/deliveries", headers: { "x-server-api-key" => "invalid" }
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "InvalidServerAPIKey"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the credential belongs to a suspended server" do
|
||||
it "returns an error" do
|
||||
server = create(:server, :suspended)
|
||||
credential = create(:credential, server: server)
|
||||
post "/api/v1/messages/deliveries", headers: { "x-server-api-key" => credential.key }
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "ServerSuspended"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the credential is valid" do
|
||||
let(:server) { create(:server) }
|
||||
let(:credential) { create(:credential, server: server) }
|
||||
|
||||
context "when no message ID is provided" do
|
||||
it "returns an error" do
|
||||
post "/api/v1/messages/deliveries", headers: { "x-server-api-key" => credential.key }
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "parameter-error"
|
||||
expect(parsed_body["data"]["message"]).to match(/`id` parameter is required but is missing/)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the message ID does not exist" do
|
||||
it "returns an error" do
|
||||
post "/api/v1/messages/deliveries",
|
||||
headers: { "x-server-api-key" => credential.key,
|
||||
"content-type" => "application/json" },
|
||||
params: { id: 123 }.to_json
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "MessageNotFound"
|
||||
expect(parsed_body["data"]["id"]).to eq 123
|
||||
end
|
||||
end
|
||||
|
||||
# Regression test for GHSA-x2hq-rfpg-3xr5 (see message_spec.rb). A JSON
|
||||
# object supplied for `id` must be rejected before reaching the database
|
||||
# rather than being interpreted as a raw set of SQL conditions.
|
||||
context "when the message ID is a JSON object (SQL injection attempt)" do
|
||||
it "rejects it with a parameter error and never reaches the database" do
|
||||
expect_any_instance_of(Server).not_to receive(:message)
|
||||
post "/api/v1/messages/deliveries",
|
||||
headers: { "x-server-api-key" => credential.key,
|
||||
"content-type" => "application/json" },
|
||||
params: { id: { "id`=0 OR SLEEP(5)#" => "x" } }.to_json
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "parameter-error"
|
||||
expect(parsed_body["data"]["message"]).to match(/must be a string or integer/)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the message ID exists" do
|
||||
let(:server) { create(:server) }
|
||||
let(:credential) { create(:credential, server: server) }
|
||||
let(:message) { MessageFactory.outgoing(server) }
|
||||
|
||||
before do
|
||||
message.create_delivery("SoftFail", details: "no server found",
|
||||
output: "404",
|
||||
sent_with_ssl: true,
|
||||
log_id: "1234",
|
||||
time: 1.2)
|
||||
message.create_delivery("Sent", details: "sent successfully",
|
||||
output: "200",
|
||||
sent_with_ssl: false,
|
||||
log_id: "5678",
|
||||
time: 2.2)
|
||||
end
|
||||
|
||||
before do
|
||||
post "/api/v1/messages/deliveries",
|
||||
headers: { "x-server-api-key" => credential.key,
|
||||
"content-type" => "application/json" },
|
||||
params: { id: message.id }.to_json
|
||||
end
|
||||
|
||||
it "returns an array of deliveries" do
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]).to match([
|
||||
{ "id" => kind_of(Integer),
|
||||
"status" => "SoftFail",
|
||||
"details" => "no server found",
|
||||
"output" => "404",
|
||||
"sent_with_ssl" => true,
|
||||
"log_id" => "1234",
|
||||
"time" => 1.2,
|
||||
"timestamp" => kind_of(Float) },
|
||||
{ "id" => kind_of(Integer),
|
||||
"status" => "Sent",
|
||||
"details" => "sent successfully",
|
||||
"output" => "200",
|
||||
"sent_with_ssl" => false,
|
||||
"log_id" => "5678",
|
||||
"time" => 2.2,
|
||||
"timestamp" => kind_of(Float) },
|
||||
])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,363 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Legacy Messages API", type: :request do
|
||||
describe "/api/v1/messages/message" do
|
||||
context "when no authentication is provided" do
|
||||
it "returns an error" do
|
||||
post "/api/v1/messages/message"
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "AccessDenied"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the credential does not match anything" do
|
||||
it "returns an error" do
|
||||
post "/api/v1/messages/message", headers: { "x-server-api-key" => "invalid" }
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "InvalidServerAPIKey"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the credential belongs to a suspended server" do
|
||||
it "returns an error" do
|
||||
server = create(:server, :suspended)
|
||||
credential = create(:credential, server: server)
|
||||
post "/api/v1/messages/message", headers: { "x-server-api-key" => credential.key }
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "ServerSuspended"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the credential is valid" do
|
||||
let(:server) { create(:server) }
|
||||
let(:credential) { create(:credential, server: server) }
|
||||
|
||||
context "when no message ID is provided" do
|
||||
it "returns an error" do
|
||||
post "/api/v1/messages/message", headers: { "x-server-api-key" => credential.key }
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "parameter-error"
|
||||
expect(parsed_body["data"]["message"]).to match(/`id` parameter is required but is missing/)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the message ID does not exist" do
|
||||
it "returns an error" do
|
||||
post "/api/v1/messages/message",
|
||||
headers: { "x-server-api-key" => credential.key,
|
||||
"content-type" => "application/json" },
|
||||
params: { id: 123 }.to_json
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "MessageNotFound"
|
||||
end
|
||||
end
|
||||
|
||||
# Regression tests for GHSA-x2hq-rfpg-3xr5. The request body is parsed as
|
||||
# JSON, so a JSON object/array supplied for `id` would otherwise arrive as
|
||||
# a Ruby Hash/Array and be passed straight through to the message database
|
||||
# as a raw set of SQL conditions (blind SQL injection). These must be
|
||||
# rejected before reaching the database.
|
||||
context "when the message ID is a JSON object (SQL injection attempt)" do
|
||||
it "rejects it with a parameter error and never reaches the database" do
|
||||
expect_any_instance_of(Server).not_to receive(:message)
|
||||
post "/api/v1/messages/message",
|
||||
headers: { "x-server-api-key" => credential.key,
|
||||
"content-type" => "application/json" },
|
||||
params: { id: { "id`=0 OR SLEEP(5)#" => "x" } }.to_json
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "parameter-error"
|
||||
expect(parsed_body["data"]["message"]).to match(/must be a string or integer/)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the message ID is a JSON array" do
|
||||
it "rejects it with a parameter error" do
|
||||
post "/api/v1/messages/message",
|
||||
headers: { "x-server-api-key" => credential.key,
|
||||
"content-type" => "application/json" },
|
||||
params: { id: [1, 2, 3] }.to_json
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "parameter-error"
|
||||
expect(parsed_body["data"]["message"]).to match(/must be a string or integer/)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the message ID is provided as a numeric string" do
|
||||
let(:message) { MessageFactory.outgoing(server) }
|
||||
|
||||
it "is coerced to an integer and looks the message up" do
|
||||
post "/api/v1/messages/message",
|
||||
headers: { "x-server-api-key" => credential.key,
|
||||
"content-type" => "application/json" },
|
||||
params: { id: message.id.to_s }.to_json
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]).to match({
|
||||
"id" => message.id,
|
||||
"token" => message.token
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context "when the message ID exists" do
|
||||
let(:server) { create(:server) }
|
||||
let(:credential) { create(:credential, server: server) }
|
||||
let(:message) { MessageFactory.outgoing(server) }
|
||||
let(:expansions) { [] }
|
||||
|
||||
before do
|
||||
post "/api/v1/messages/message",
|
||||
headers: { "x-server-api-key" => credential.key,
|
||||
"content-type" => "application/json" },
|
||||
params: { id: message.id, _expansions: expansions }.to_json
|
||||
end
|
||||
|
||||
context "when no expansions are requested" do
|
||||
it "returns details about the message" do
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]).to match({
|
||||
"id" => message.id,
|
||||
"token" => message.token
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context "when all expansions are requested" do
|
||||
let(:expansions) { true }
|
||||
|
||||
it "returns details about the message" do
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]).to match({
|
||||
"id" => message.id,
|
||||
"token" => message.token,
|
||||
"status" => { "held" => false,
|
||||
"hold_expiry" => nil,
|
||||
"last_delivery_attempt" => nil,
|
||||
"status" => "Pending" },
|
||||
"details" => { "bounce" => false,
|
||||
"bounce_for_id" => 0,
|
||||
"direction" => "outgoing",
|
||||
"mail_from" => "test@example.com",
|
||||
"message_id" => message.message_id,
|
||||
"rcpt_to" => "john@example.com",
|
||||
"received_with_ssl" => nil,
|
||||
"size" => kind_of(String),
|
||||
"subject" => "An example message",
|
||||
"tag" => nil,
|
||||
"timestamp" => kind_of(Float) },
|
||||
"inspection" => { "inspected" => false,
|
||||
"spam" => false,
|
||||
"spam_score" => 0.0,
|
||||
"threat" => false,
|
||||
"threat_details" => nil },
|
||||
"plain_body" => message.plain_body,
|
||||
"html_body" => message.html_body,
|
||||
"attachments" => [],
|
||||
"headers" => message.headers,
|
||||
"raw_message" => Base64.encode64(message.raw_message),
|
||||
"activity_entries" => {
|
||||
"loads" => [],
|
||||
"clicks" => []
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context "when the status expansion is requested" do
|
||||
let(:expansions) { ["status"] }
|
||||
|
||||
it "returns details about the message" do
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]).to match({
|
||||
"id" => message.id,
|
||||
"token" => message.token,
|
||||
"status" => { "held" => false,
|
||||
"hold_expiry" => nil,
|
||||
"last_delivery_attempt" => nil,
|
||||
"status" => "Pending" }
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context "when the details expansion is requested" do
|
||||
let(:expansions) { ["details"] }
|
||||
|
||||
it "returns details about the message" do
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]).to match({
|
||||
"id" => message.id,
|
||||
"token" => message.token,
|
||||
"details" => { "bounce" => false,
|
||||
"bounce_for_id" => 0,
|
||||
"direction" => "outgoing",
|
||||
"mail_from" => "test@example.com",
|
||||
"message_id" => message.message_id,
|
||||
"rcpt_to" => "john@example.com",
|
||||
"received_with_ssl" => nil,
|
||||
"size" => kind_of(String),
|
||||
"subject" => "An example message",
|
||||
"tag" => nil,
|
||||
"timestamp" => kind_of(Float) }
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context "when the details expansion is requested" do
|
||||
let(:expansions) { ["inspection"] }
|
||||
|
||||
it "returns details about the message" do
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]).to match({
|
||||
"id" => message.id,
|
||||
"token" => message.token,
|
||||
"inspection" => { "inspected" => false,
|
||||
"spam" => false,
|
||||
"spam_score" => 0.0,
|
||||
"threat" => false,
|
||||
"threat_details" => nil }
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context "when the body expansions are requested" do
|
||||
let(:expansions) { %w[plain_body html_body] }
|
||||
|
||||
it "returns details about the message" do
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]).to match({
|
||||
"id" => message.id,
|
||||
"token" => message.token,
|
||||
"plain_body" => message.plain_body,
|
||||
"html_body" => message.html_body
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context "when the attachments expansions is requested" do
|
||||
let(:message) do
|
||||
MessageFactory.outgoing(server) do |_, mail|
|
||||
mail.attachments["example.txt"] = "hello world!"
|
||||
end
|
||||
end
|
||||
let(:expansions) { ["attachments"] }
|
||||
|
||||
it "returns details about the message" do
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]).to match({
|
||||
"id" => message.id,
|
||||
"token" => message.token,
|
||||
"attachments" => [
|
||||
{
|
||||
"content_type" => "text/plain",
|
||||
"data" => Base64.encode64("hello world!"),
|
||||
"filename" => "example.txt",
|
||||
"hash" => Digest::SHA1.hexdigest("hello world!"),
|
||||
"size" => 12
|
||||
},
|
||||
]
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context "when the headers expansions is requested" do
|
||||
let(:expansions) { ["headers"] }
|
||||
|
||||
it "returns details about the message" do
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]).to match({
|
||||
"id" => message.id,
|
||||
"token" => message.token,
|
||||
"headers" => message.headers
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context "when the raw_message expansions is requested" do
|
||||
let(:expansions) { ["raw_message"] }
|
||||
|
||||
it "returns details about the message" do
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]).to match({
|
||||
"id" => message.id,
|
||||
"token" => message.token,
|
||||
"raw_message" => Base64.encode64(message.raw_message)
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context "when the activity_entries expansions is requested" do
|
||||
let(:message) do
|
||||
MessageFactory.outgoing(server) do |msg|
|
||||
msg.create_load(double("request", ip: "1.2.3.4", user_agent: "user agent"))
|
||||
link = msg.create_link("https://example.com")
|
||||
link_id = msg.database.select(:links, where: { token: link }).first["id"]
|
||||
msg.database.insert(:clicks, {
|
||||
message_id: msg.id,
|
||||
link_id: link_id,
|
||||
ip_address: "1.2.3.4",
|
||||
user_agent: "user agent",
|
||||
timestamp: Time.now.to_f
|
||||
})
|
||||
end
|
||||
end
|
||||
let(:expansions) { ["activity_entries"] }
|
||||
|
||||
it "returns details about the message" do
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]).to match({
|
||||
"id" => message.id,
|
||||
"token" => message.token,
|
||||
"activity_entries" => {
|
||||
"loads" => [{
|
||||
"ip_address" => "1.2.3.4",
|
||||
"user_agent" => "user agent",
|
||||
"timestamp" => match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\z/)
|
||||
}],
|
||||
"clicks" => [{
|
||||
"url" => "https://example.com",
|
||||
"ip_address" => "1.2.3.4",
|
||||
"user_agent" => "user agent",
|
||||
"timestamp" => match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\z/)
|
||||
}]
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,237 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Legacy Send API", type: :request do
|
||||
describe "/api/v1/send/message" do
|
||||
context "when no authentication is provided" do
|
||||
it "returns an error" do
|
||||
post "/api/v1/send/message"
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "AccessDenied"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the credential does not match anything" do
|
||||
it "returns an error" do
|
||||
post "/api/v1/send/message", headers: { "x-server-api-key" => "invalid" }
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "InvalidServerAPIKey"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the credential belongs to a suspended server" do
|
||||
it "returns an error" do
|
||||
server = create(:server, :suspended)
|
||||
credential = create(:credential, server: server)
|
||||
post "/api/v1/send/message", headers: { "x-server-api-key" => credential.key }
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "ServerSuspended"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the credential is valid" do
|
||||
let(:server) { create(:server) }
|
||||
let(:credential) { create(:credential, server: server) }
|
||||
let(:domain) { create(:domain, owner: server) }
|
||||
|
||||
context "when parameters are provided in a JSON body" do
|
||||
let(:default_params) do
|
||||
{
|
||||
to: ["test@example.com"],
|
||||
cc: ["cc@example.com"],
|
||||
bcc: ["bcc@example.com"],
|
||||
from: "test@#{domain.name}",
|
||||
sender: "sender@#{domain.name}",
|
||||
tag: "test-tag",
|
||||
reply_to: "reply@example.com",
|
||||
plain_body: "plain text",
|
||||
html_body: "<p>html</p>",
|
||||
attachments: [{ name: "test1.txt", content_type: "text/plain", data: Base64.encode64("hello world 1") },
|
||||
{ name: "test2.txt", content_type: "text/plain", data: Base64.encode64("hello world 2") },],
|
||||
headers: { "x-test-header-1" => "111", "x-test-header-2" => "222" },
|
||||
bounce: false,
|
||||
subject: "Test"
|
||||
}
|
||||
end
|
||||
let(:params) { default_params }
|
||||
|
||||
before do
|
||||
post "/api/v1/send/message",
|
||||
headers: { "x-server-api-key" => credential.key,
|
||||
"content-type" => "application/json" },
|
||||
params: params.to_json
|
||||
end
|
||||
|
||||
context "when no recipients are provided" do
|
||||
let(:params) { default_params.merge(to: [], cc: [], bcc: []) }
|
||||
|
||||
it "returns an error" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "NoRecipients"
|
||||
expect(parsed_body["data"]["message"]).to match(/there are no recipients defined to receive this message/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "when no content is provided" do
|
||||
let(:params) { default_params.merge(html_body: nil, plain_body: nil) }
|
||||
|
||||
it "returns an error" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "NoContent"
|
||||
expect(parsed_body["data"]["message"]).to match(/there is no content defined for this e-mail/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the number of 'To' recipients exceeds the maximum" do
|
||||
let(:params) { default_params.merge(to: ["a@a.com"] * 51) }
|
||||
|
||||
it "returns an error" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "TooManyToAddresses"
|
||||
expect(parsed_body["data"]["message"]).to match(/the maximum number of To addresses has been reached/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the number of 'CC' recipients exceeds the maximum" do
|
||||
let(:params) { default_params.merge(cc: ["a@a.com"] * 51) }
|
||||
|
||||
it "returns an error" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "TooManyCCAddresses"
|
||||
expect(parsed_body["data"]["message"]).to match(/the maximum number of CC addresses has been reached/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the number of 'BCC' recipients exceeds the maximum" do
|
||||
let(:params) { default_params.merge(bcc: ["a@a.com"] * 51) }
|
||||
|
||||
it "returns an error" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "TooManyBCCAddresses"
|
||||
expect(parsed_body["data"]["message"]).to match(/the maximum number of BCC addresses has been reached/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the 'From' address is missing" do
|
||||
let(:params) { default_params.merge(from: nil) }
|
||||
|
||||
it "returns an error" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "FromAddressMissing"
|
||||
expect(parsed_body["data"]["message"]).to match(/the from address is missing and is required/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the 'From' address is not authorised" do
|
||||
let(:params) { default_params.merge(from: "test@another.com") }
|
||||
|
||||
it "returns an error" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "UnauthenticatedFromAddress"
|
||||
expect(parsed_body["data"]["message"]).to match(/the from address is not authorised to send mail from this server/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "when an attachment is missing a name" do
|
||||
let(:params) { default_params.merge(attachments: [{ name: nil, content_type: "text/plain", data: Base64.encode64("hello world 1") }]) }
|
||||
|
||||
it "returns an error" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "AttachmentMissingName"
|
||||
expect(parsed_body["data"]["message"]).to match(/an attachment is missing a name/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "when an attachment is missing data" do
|
||||
let(:params) { default_params.merge(attachments: [{ name: "test1.txt", content_type: "text/plain", data: nil }]) }
|
||||
|
||||
it "returns an error" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "AttachmentMissingData"
|
||||
expect(parsed_body["data"]["message"]).to match(/an attachment is missing data/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "when an attachment entry is not a hash" do
|
||||
let(:params) { default_params.merge(attachments: [123, "string"]) }
|
||||
|
||||
it "continues as if it wasn't there" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
["test@example.com", "cc@example.com", "bcc@example.com"].each do |rcpt_to|
|
||||
message_id = parsed_body["data"]["messages"][rcpt_to]["id"]
|
||||
message = server.message(message_id)
|
||||
expect(message.attachments).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when given a complete email to send" do
|
||||
it "returns details of the messages created" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]["messages"]).to match({
|
||||
"test@example.com" => { "id" => kind_of(Integer), "token" => /\A[a-zA-Z0-9]{16}\z/ },
|
||||
"cc@example.com" => { "id" => kind_of(Integer), "token" => /\A[a-zA-Z0-9]{16}\z/ },
|
||||
"bcc@example.com" => { "id" => kind_of(Integer), "token" => /\A[a-zA-Z0-9]{16}\z/ }
|
||||
})
|
||||
end
|
||||
|
||||
it "adds an appropriate received header" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
message_id = parsed_body["data"]["messages"]["test@example.com"]["id"]
|
||||
message = server.message(message_id)
|
||||
expect(message.headers["received"].first).to match(/\Afrom api/)
|
||||
end
|
||||
|
||||
it "creates appropriate message objects" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
["test@example.com", "cc@example.com", "bcc@example.com"].each do |rcpt_to|
|
||||
message_id = parsed_body["data"]["messages"][rcpt_to]["id"]
|
||||
message = server.message(message_id)
|
||||
expect(message).to have_attributes(
|
||||
server: server,
|
||||
rcpt_to: rcpt_to,
|
||||
mail_from: params[:from],
|
||||
subject: params[:subject],
|
||||
message_id: kind_of(String),
|
||||
timestamp: kind_of(Time),
|
||||
domain_id: domain.id,
|
||||
credential_id: credential.id,
|
||||
bounce: false,
|
||||
tag: params[:tag],
|
||||
headers: hash_including("x-test-header-1" => ["111"],
|
||||
"x-test-header-2" => ["222"],
|
||||
"sender" => [params[:sender]],
|
||||
"to" => ["test@example.com"],
|
||||
"cc" => ["cc@example.com"],
|
||||
"reply-to" => ["reply@example.com"]),
|
||||
plain_body: params[:plain_body],
|
||||
html_body: params[:html_body],
|
||||
attachments: [
|
||||
have_attributes(content_type: /\Atext\/plain/, filename: "test1.txt", body: have_attributes(to_s: "hello world 1")),
|
||||
have_attributes(content_type: /\Atext\/plain/, filename: "test2.txt", body: have_attributes(to_s: "hello world 2")),
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,168 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Legacy Send API", type: :request do
|
||||
describe "/api/v1/send/raw" do
|
||||
context "when no authentication is provided" do
|
||||
it "returns an error" do
|
||||
post "/api/v1/send/raw"
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "AccessDenied"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the credential does not match anything" do
|
||||
it "returns an error" do
|
||||
post "/api/v1/send/raw", headers: { "x-server-api-key" => "invalid" }
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "InvalidServerAPIKey"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the credential belongs to a suspended server" do
|
||||
it "returns an error" do
|
||||
server = create(:server, :suspended)
|
||||
credential = create(:credential, server: server)
|
||||
post "/api/v1/send/raw", headers: { "x-server-api-key" => credential.key }
|
||||
expect(response.status).to eq 200
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "error"
|
||||
expect(parsed_body["data"]["code"]).to eq "ServerSuspended"
|
||||
end
|
||||
end
|
||||
|
||||
context "when the credential is valid" do
|
||||
let(:server) { create(:server) }
|
||||
let(:credential) { create(:credential, server: server) }
|
||||
let(:domain) { create(:domain, owner: server) }
|
||||
let(:data) do
|
||||
mail = Mail.new
|
||||
mail.to = "test1@example.com"
|
||||
mail.from = "test@#{domain.name}"
|
||||
mail.subject = "test"
|
||||
mail.text_part = Mail::Part.new
|
||||
mail.text_part.body = "plain text"
|
||||
mail.html_part = Mail::Part.new
|
||||
mail.html_part.content_type = "text/html; charset=UTF-8"
|
||||
mail.html_part.body = "<p>html</p>"
|
||||
mail
|
||||
end
|
||||
let(:default_params) do
|
||||
{
|
||||
mail_from: "test@#{domain.name}",
|
||||
rcpt_to: ["test1@example.com", "test2@example.com"],
|
||||
data: Base64.encode64(data.to_s),
|
||||
bounce: false
|
||||
}
|
||||
end
|
||||
let(:content_type) { "application/json" }
|
||||
let(:params) { default_params }
|
||||
|
||||
before do
|
||||
post "/api/v1/send/raw",
|
||||
headers: { "x-server-api-key" => credential.key,
|
||||
"content-type" => content_type },
|
||||
params: content_type == "application/json" ? params.to_json : params
|
||||
end
|
||||
|
||||
context "when rcpt_to is not provided" do
|
||||
let(:params) { default_params.except(:rcpt_to) }
|
||||
|
||||
it "returns an error" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "parameter-error"
|
||||
expect(parsed_body["data"]["message"]).to match(/`rcpt_to` parameter is required but is missing/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "when mail_from is not provided" do
|
||||
let(:params) { default_params.except(:mail_from) }
|
||||
|
||||
it "returns an error" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "parameter-error"
|
||||
expect(parsed_body["data"]["message"]).to match(/`mail_from` parameter is required but is missing/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "when data is not provided" do
|
||||
let(:params) { default_params.except(:data) }
|
||||
|
||||
it "returns an error" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "parameter-error"
|
||||
expect(parsed_body["data"]["message"]).to match(/`data` parameter is required but is missing/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "when no recipients are provided" do
|
||||
let(:params) { default_params.merge(rcpt_to: []) }
|
||||
|
||||
it "returns success but with no messages" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["status"]).to eq "success"
|
||||
expect(parsed_body["data"]["messages"]).to eq({})
|
||||
expect(parsed_body["data"]["message_id"]).to be nil
|
||||
end
|
||||
end
|
||||
|
||||
context "when a valid email is provided" do
|
||||
it "returns details of the messages created" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["data"]["message_id"]).to be_a String
|
||||
expect(parsed_body["data"]["messages"]).to be_a Hash
|
||||
expect(parsed_body["data"]["messages"]).to match({
|
||||
"test1@example.com" => { "id" => kind_of(Integer), "token" => /\A[a-zA-Z0-9]{16}\z/ },
|
||||
"test2@example.com" => { "id" => kind_of(Integer), "token" => /\A[a-zA-Z0-9]{16}\z/ }
|
||||
})
|
||||
end
|
||||
|
||||
it "creates appropriate message objects" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
["test1@example.com", "test2@example.com"].each do |rcpt_to|
|
||||
message_id = parsed_body["data"]["messages"][rcpt_to]["id"]
|
||||
message = server.message(message_id)
|
||||
expect(message).to have_attributes(
|
||||
server: server,
|
||||
rcpt_to: rcpt_to,
|
||||
mail_from: "test@#{domain.name}",
|
||||
subject: "test",
|
||||
message_id: kind_of(String),
|
||||
timestamp: kind_of(Time),
|
||||
domain_id: domain.id,
|
||||
credential_id: credential.id,
|
||||
bounce: false,
|
||||
headers: hash_including("to" => ["test1@example.com"]),
|
||||
plain_body: "plain text",
|
||||
html_body: "<p>html</p>",
|
||||
attachments: [],
|
||||
received_with_ssl: true,
|
||||
scope: "outgoing",
|
||||
raw_message: data.to_s
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "when params are provided as a param" do
|
||||
let(:content_type) { nil }
|
||||
let(:params) { { params: default_params.to_json } }
|
||||
|
||||
it "returns details of the messages created" do
|
||||
parsed_body = JSON.parse(response.body)
|
||||
expect(parsed_body["data"]["message_id"]).to be_a String
|
||||
expect(parsed_body["data"]["messages"]).to be_a Hash
|
||||
expect(parsed_body["data"]["messages"]).to match({
|
||||
"test1@example.com" => { "id" => kind_of(Integer), "token" => /\A[a-zA-Z0-9]{16}\z/ },
|
||||
"test2@example.com" => { "id" => kind_of(Integer), "token" => /\A[a-zA-Z0-9]{16}\z/ }
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -19,5 +19,11 @@ FactoryBot.define do
|
||||
factory :ip_pool do
|
||||
name { "Default Pool" }
|
||||
default { true }
|
||||
|
||||
trait :with_ip_address do
|
||||
after(:create) do |ip_pool|
|
||||
ip_pool.ip_addresses << create(:ip_address, ip_pool: ip_pool)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user