Compare commits

...
72 Commits
Author SHA1 Message Date
pplulee ced77a3860 feat: add configurable Postal logger level defaulting to INFO (#3626) 2026-09-13 13:12:57 +01:00
pplulee 3be663dc89 fix: give each worker process a unique locker identity (#3628) 2026-09-13 13:11:42 +01:00
github-actions[bot] d038eaa8c7 chore(main): release 3.3.7 (#3577)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-03 16:24:45 +01:00
Adam Cooke 3b3defe271 doc: update config with new allowed request destinations option 2026-06-03 15:52:46 +01:00
Adam Cooke 029bfe098d fix(specs): stub IPv6 support in address guard IPv6 literal spec
The spec relied on the test machine having real IPv6 connectivity,
which GitHub Actions runners do not have.
2026-06-03 15:43:02 +01:00
Adam Cooke 0445e5c509 chore(deps): upgrade rack & rails 2026-06-03 15:40:29 +01:00
Adam Cooke 11c9814474 fix(http): prevent SSRF in outbound webhook and HTTP endpoint requests
Webhook and HTTP message endpoint deliveries both flow through
Postal::HTTP, which parsed the user-supplied URL and connected to its
host with no address validation. An authenticated user could point a
webhook or endpoint at a private, loopback or link-local address (e.g.
127.0.0.1, 169.254.169.254 cloud metadata, RFC1918 hosts) and make the
server issue requests into its own internal network.

Add Postal::HTTP::AddressGuard, which resolves the destination host and
rejects private/loopback/link-local/reserved/multicast IPv4 and IPv6
addresses, then pins the connection to the validated address so it cannot
be redirected via a DNS-rebinding race. Administrators can permit specific
destinations via the new postal.allowed_request_destinations config option
(hostnames or IP/CIDR ranges).

Address selection only uses families this server can actually reach so we
do not pin to an IPv6 address on a host without IPv6 connectivity; IPv4 is
preferred for predictability. HTTPEndpoint now validates that its URL is a
well-formed HTTP(S) URL with a host.
2026-06-03 15:09:18 +01:00
Adam Cooke 4314a6ec1e fix(message-db): prevent SQL injection via condition keys (GHSA-x2hq-rfpg-3xr5)
The Legacy API message lookup endpoints parsed the request body as JSON and
passed the `id` parameter straight through to the message database. A JSON
object supplied for `id` arrived as a Ruby Hash and was used as a raw set of
SQL `WHERE` conditions. `hash_to_sql` interpolated each Hash key directly
inside backtick identifier quoting while escaping only the value, so a key
containing a backtick could break out of the identifier and inject arbitrary
SQL into the SELECT (blind, time-based) against the message database.

Fixes:

- Escape all identifiers (columns, tables, database names) through a new
  `escape_identifier` helper that wraps in backticks and doubles embedded
  backticks. Applied across hash_to_sql, select, insert, insert_multi,
  update and delete so no caller can inject via an identifier.
- Validate the Legacy API `id` parameter at the controller boundary: reject
  any non-scalar value before it reaches the database and coerce it to an
  integer. Internal Hash-based lookups (e.g. tracking middleware) are
  unaffected.

Adds regression tests at the unit (hash_to_sql / escape_identifier) and
request (legacy messages/deliveries) levels.
2026-06-03 15:06:35 +01:00
Adam Cooke 8be1e27fec chore: update security vulnerability reporting instructions 2026-06-03 15:03:51 +01:00
github-actions[bot] 8ef89606bc chore(main): release 3.3.6 (#3558)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-28 17:19:33 +01:00
Adam Cooke 84f4e20f05 refactor(auth): tighten return_to validation
url_with_return_to only checked that return_to started with a forward
slash, which also allowed protocol-relative values like //host and
/\host. Rails 7.1 already refuses to follow those via redirect_to, so
the user just saw a 500. Reject the same shapes in the helper instead
so we fall back to the default URL cleanly.

Adds a sessions request spec covering the rejected shapes plus the
happy-path relative redirect.
2026-04-24 23:03:50 +01:00
Adam Cooke 9243524035 refactor(helpers): escape interpolated values in select options
The endpoint and domain option helpers interpolated model attributes
straight into an HTML string before marking the whole buffer html_safe.
Wrap the interpolations in h() so untrusted attributes can't break out
of the surrounding tag.

Also stop the helpers glob in rails_helper from eagerly requiring
_spec.rb files so helper specs can live under spec/helpers/, and add a
small application helper spec covering the escape behaviour.
2026-04-24 22:55:46 +01:00
Adam Cooke dca7f90b90 refactor(tracking): remove unused src image proxy
The /img/<server>/<message> endpoint accepted a src=<url> query
parameter and proxied the body of that URL back to the caller. Nothing
in the codebase ever produces a src= parameter — the parser only
inserts a plain tracking pixel and rewrites href links — so this branch
is dead code inherited from the original AppMail import.

Drop the src branch: requests with src now return 400. The no-src path
that serves the tracking pixel and records loads is unchanged, and a
spec covers both the pixel-serving path and the removed branch.
2026-04-24 22:24:18 +01:00
Adam Cooke cad2aa6808 fix(messages): sandbox rendered email HTML as extra XSS defence
The app-wide CSP already blocks inline script execution, but the HTML
preview iframe for a stored email was same-origin and un-sandboxed, and
the html_raw response had no per-action hardening. Add a sandbox on the
iframe and tighten the CSP on html_raw to script-src 'none' with
nosniff and no-referrer so the preview has defence in depth against a
future CSP bypass or regression.

Relates to GHSA-f6g9-8555-cw28.
2026-04-24 22:12:27 +01:00
Adam Cooke b611d577af chore: ignore node modules and yarn.lock 2026-04-24 21:34:57 +01:00
github-actions[bot] d532922ff7 chore(main): release 3.3.5 (#3208)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-01 14:55:42 +00:00
Adam Cooke 11419f9914 fix(deliveries): escape delivery details to prevent HTML injection 2026-02-01 14:48:54 +00:00
Melle Douwsma b7e5232e07 fix: typo in process logging (#3212)
Signed-off-by: Melle Douwsma <melledouwsma@users.noreply.github.com>
2025-10-03 09:41:20 +01:00
Johan Kok e00098b800 fix: update url for v2 config (#3225)
This minor update resolves #3048

Signed-off-by: Johan Kok <johankok@users.noreply.github.com>
2025-10-03 09:40:22 +01:00
Adam Cooke d00d978872 chore: upgrade resolv to 0.6.2 2025-10-02 14:39:27 +01:00
Adam Cooke c78000ca8f chore: remove version from docker-compose.yml 2025-10-02 14:38:42 +01:00
Adam Cooke c03c44b442 chore(deps): upgrade puma, net-imap and other deps 2025-10-01 18:13:36 +01:00
Adam Cooke 86de372382 chore(dockerfile): reduce container size 2025-10-01 18:12:26 +01:00
Adam Cooke 7c47422c86 fix(health_server): use rackup handler instead of rack handler 2025-10-01 18:12:26 +01:00
Arthur Lutz f5325c49ff docs(process.rb): add help about time unit used by metric (#3339)
Signed-off-by: Arthur Lutz <arthur.lutz@zenika.com>
2025-10-01 17:31:13 +01:00
Adam Cooke f193b8e77f chore: upgrade uri gem to 1.0.3 2025-10-01 16:47:59 +01:00
Adam Cooke ab6d4430ba chore: upgrade to rails 7.1 and ruby 3.4 (#3457) 2025-10-01 16:42:39 +01:00
Matthieu Barthel 9c5f96ae90 fix: oidc scopes are invalid when concatenated (#3332) 2025-05-08 07:51:46 +01:00
Som23Git fd3c7ccdf6 fix: typo in the credentials page 2024-10-31 17:53:20 +00:00
github-actions[bot] da90e75036 chore(main): release 3.3.4 (#3014)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-06-20 14:39:58 +01:00
Adam Cooke 2b0919c145 fix: raise NotImplementedError when no call method on a scheduled task 2024-06-20 14:27:20 +01:00
Adam Cooke 3a33e53d84 fix: fix issue running message pruning task 2024-06-20 14:27:20 +01:00
Adam Cooke 4fa88acea0 fix: fix postal version command 2024-06-20 14:27:18 +01:00
github-actions[bot] d510499190 chore(main): release 3.3.3 (#2933)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-04-18 14:42:30 +01:00
Adam Cooke 39f704c256 fix(legacy-api): allow _expansions to be provided as true to return all expansions
closes #2932
2024-04-18 14:38:44 +01:00
github-actions[bot] c12f30e300 chore(main): release 3.3.2 (#2892)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-03-22 10:40:20 +00:00
Adam Cooke 5a2f31ed77 doc: fix doc for Postal.version 2024-03-21 14:58:11 +00:00
Adam Cooke 07c6b317f2 refactor(versioning): improve how current version and branch is determined and set
Refactor `Postal.version`` and `Postal.branch` and remove `Postal::VERSION`.
2024-03-21 14:55:14 +00:00
github-actions[bot] a3fab36da2 chore(main): release 3.3.1 (#2890)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-03-21 12:54:43 +00:00
Adam Cooke 3a56ec8a74 fix(smtp-sender): ensure relays without a host are excluded 2024-03-21 12:33:34 +00:00
Adam Cooke b3264b9427 fix(smtp-sender): fixes SMTPSender.smtp_relays 2024-03-21 12:27:54 +00:00
Adam Cooke 6ef388577e Merge pull request #2891 from postalserver/export-branch-to-image 2024-03-20 14:34:28 +00:00
Adam Cooke 18236171eb chore(ui): display branch in footer if present 2024-03-20 14:30:32 +00:00
Adam Cooke bee509832e chore(container): add the branch name to the container 2024-03-20 14:28:44 +00:00
Adam Cooke 4d9654dac4 refactor: remove moonrope but maintain legacy API actions (#2889) 2024-03-19 20:21:04 +00:00
Adam Cooke adaf2b0750 chore(github-actions): don't run for dependabot or release-please PRs and fetch whole repo 2024-03-19 16:54:18 +00:00
Adam Cooke 64bc7dcf7c chore(github-actions): include a version string on branch-*/latest images 2024-03-19 16:42:21 +00:00
Adam Cooke d65bbe0579 chore(github-actions): don't generate commit- tags 2024-03-19 10:27:15 +00:00
github-actions[bot] eded789c37 chore(main): release 3.3.0 (#2887)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-03-18 17:59:05 +00:00
Adam Cooke ea542a0694 feat(worker): scale connection pool with worker threads
This will automatically increase the DB connection pool size if the number of threads needed in a worker is less than the maximum pool size configured.
2024-03-18 08:12:42 +00:00
Adam Cooke 7e2acccd1e feat(worker): allow number of threads to be configured
This allows for more threads to be run. Care needs to be taken to ensure that database connection pool size is appropriate for this.
2024-03-17 18:41:26 +00:00
Adam Cooke ee8d829a85 feat(prometheus): add postal_message_queue_latency metric 2024-03-17 09:29:22 +00:00
Adam Cooke 4fcb9e9a2e fix(message-dequeuer): ability to disable batching 2024-03-16 15:31:46 +00:00
Adam Cooke 45dd8aaac5 chore(config-docs): update proxy protocol to mention v1 2024-03-16 15:31:14 +00:00
Adam Cooke 364eba6c5f chore(config-docs): update docs for latest oidc defaults 2024-03-16 15:30:50 +00:00
github-actions[bot] 6a1ff56fe2 chore(main): release 3.2.2 (#2878)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-03-14 22:03:58 +00:00
Adam Cooke be456523dd fix: don't use authentication on org & server deletion 2024-03-14 14:14:59 +00:00
Adam Cooke 3d208d632f test: add tests for the legacy API 2024-03-13 22:40:41 +00:00
Adam Cooke 1c67f72209 chore: hide further config messages 2024-03-13 19:08:59 +00:00
Adam Cooke aa76aae232 chore: suppress config location on default-dkim-record 2024-03-13 19:06:37 +00:00
Adam Cooke f760cdb5a1 chore: allow config location message to be suppressed 2024-03-13 19:06:25 +00:00
Adam Cooke b55becd2ec doc: add annotations to queued message spec 2024-03-13 19:02:59 +00:00
Adam Cooke 92406129cf fix(smtp-server): fixes proxy protocol 2024-03-13 18:16:08 +00:00
github-actions[bot] 144af20b9c chore(main): release 3.2.1 (#2877)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-03-13 17:01:01 +00:00
Adam Cooke 58dddebeb8 fix: fixes postal default-dkim-record 2024-03-13 16:54:53 +00:00
github-actions[bot] 8e71dd0232 chore(main): release 3.2.0 (#2867)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-03-13 11:17:54 +00:00
Adam Cooke 96d73653d7 feat: add sha256 signatures to outgoing http requests (#2874) 2024-03-13 08:52:29 +00:00
Adam Cooke 9982bb8c31 fix(smtp-server): add additional information to cram-md5 log entries 2024-03-12 20:54:40 +00:00
Adam Cooke 5ed94f6f85 feat: openid connect support (#2873) 2024-03-12 17:40:07 +00:00
Adam Cooke 4e13577891 style(rubocop): Style/TrailingCommaInArrayLiteral 2024-03-12 11:45:44 +00:00
Adam Cooke d84152eb5d feat: automatically remove queued messages with stale locks (#2872) 2024-03-12 11:37:29 +00:00
Adam Cooke 5d8213a987 chore: regenerate config docs 2024-03-11 21:55:15 +00:00
119 changed files with 4468 additions and 908 deletions
+29 -11
View File
@@ -52,10 +52,10 @@ jobs:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- run: docker-compose pull - run: docker compose pull
env: env:
POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }} 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: env:
POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }} POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }}
@@ -63,9 +63,14 @@ jobs:
name: Release (branch) name: Release (branch)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build] 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: docker/setup-qemu-action@v3 - uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v2 - uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2 - uses: docker/login-action@v2
@@ -73,22 +78,35 @@ jobs:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- id: tag - id: info
run: | run: |
TAG="${GITHUB_REF#refs/heads/}" IMAGE=ghcr.io/postalserver/postal
if [ -z "$TAG" ]; then exit 1; fi
if [[ $TAG == "main" ]]; then TAG="latest"; else TAG="branch-${TAG}"; fi REF="${GITHUB_REF#refs/heads/}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT 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 - uses: docker/build-push-action@v4
with: with:
push: true push: true
tags: | tags: ${{ steps.info.outputs.tags }}
ghcr.io/postalserver/postal:${{ steps.tag.outputs.tag }}
ghcr.io/postalserver/postal:commit-${{ github.sha }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
target: full target: full
platforms: linux/amd64 platforms: linux/amd64
build-args: |
VERSION=${{ steps.info.outputs.version }}
BRANCH=${{ steps.info.outputs.branch }}
publish-image: publish-image:
name: Publish Image name: Publish Image
+4
View File
@@ -29,7 +29,11 @@ vendor/bundle
Procfile.local Procfile.local
VERSION VERSION
BRANCH
.rubocop-https* .rubocop-https*
.env* .env*
node_modules
yarn.lock
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "3.1.1" ".": "3.3.7"
} }
+3
View File
@@ -125,6 +125,9 @@ Style/MultilineBlockChain:
Exclude: Exclude:
- "spec/**/*.rb" - "spec/**/*.rb"
Style/TrailingCommaInArrayLiteral:
EnforcedStyleForMultiline: consistent_comma
Metrics/AbcSize: Metrics/AbcSize:
Enabled: false Enabled: false
+1 -1
View File
@@ -1 +1 @@
3.2.2 3.4.6
+177
View File
@@ -2,6 +2,183 @@
This file contains all the latest changes and updates to Postal. 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) ## [3.1.1](https://github.com/postalserver/postal/compare/3.1.0...3.1.1) (2024-03-08)
+19 -14
View File
@@ -1,22 +1,25 @@
FROM ruby:3.2.2-bullseye AS base FROM ruby:3.4.6-slim-bookworm AS base
SHELL ["/bin/bash", "-o", "pipefail", "-c"] SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install --no-install-recommends -y curl \
software-properties-common dirmngr apt-transport-https \ && apt-get clean \
&& (curl -sL https://deb.nodesource.com/setup_20.x | bash -) \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN (curl -sL https://deb.nodesource.com/setup_20.x | bash -)
# Install main dependencies # Install main dependencies
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
build-essential \ build-essential \
netcat \ netcat-openbsd \
curl \ libmariadb-dev \
libmariadb-dev \ libcap2-bin \
libcap2-bin \ nano \
nano \ libyaml-dev \
nodejs nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/ruby 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 WORKDIR /opt/postal/app
# Install bundler # 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 # Install the latest and active gem dependencies and re-run
# the appropriate commands to handle installs. # the appropriate commands to handle installs.
@@ -43,8 +46,10 @@ COPY ./docker/wait-for.sh /docker-entrypoint.sh
COPY --chown=postal . . COPY --chown=postal . .
# Export the version # Export the version
ARG VERSION=unspecified ARG VERSION
RUN echo $VERSION > 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 # Set paths for when running in a container
ENV POSTAL_CONFIG_FILE_PATH=/config/postal.yml ENV POSTAL_CONFIG_FILE_PATH=/config/postal.yml
+21 -7
View File
@@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
source "https://rubygems.org" source "https://rubygems.org"
gem "abbrev"
gem "authie" gem "authie"
gem "autoprefixer-rails" gem "autoprefixer-rails"
gem "bcrypt" gem "bcrypt"
@@ -8,30 +9,40 @@ gem "chronic"
gem "domain_name" gem "domain_name"
gem "dotenv" gem "dotenv"
gem "dynamic_form" gem "dynamic_form"
gem "encrypto_signo"
gem "execjs", "~> 2.7", "< 2.8" gem "execjs", "~> 2.7", "< 2.8"
gem "gelf" gem "gelf"
gem "haml" gem "haml"
gem "hashie" gem "hashie"
gem "highline", require: false gem "highline", require: false
gem "jwt"
gem "kaminari" gem "kaminari"
gem "klogger-logger" gem "klogger-logger"
gem "konfig-config", "~> 3.0" gem "konfig-config", "~> 3.0"
gem "logger"
gem "mail" gem "mail"
gem "moonrope" gem "mutex_m"
gem "mysql2" gem "mysql2"
gem "nifty-utils" gem "nifty-utils"
gem "nilify_blanks" gem "nilify_blanks"
gem "nio4r" gem "nio4r"
gem "ostruct"
gem "prometheus-client" gem "prometheus-client"
gem "puma" gem "puma"
gem "rails", "= 7.0.8.1" gem "rackup"
gem "rails", "= 7.1.6"
gem "resolv" gem "resolv"
gem "secure_headers" gem "secure_headers"
gem "sentry-rails" gem "sentry-rails"
gem "turbolinks", "~> 5" gem "turbolinks", "~> 5"
gem "webrick" gem "webrick"
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, :assets do group :development, :assets do
gem "coffee-rails", "~> 5.0" gem "coffee-rails", "~> 5.0"
gem "jquery-rails" gem "jquery-rails"
@@ -41,12 +52,15 @@ end
group :development do group :development do
gem "annotate" 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"
gem "rubocop-rails" 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 "shoulda-matchers"
gem "timecop" gem "timecop"
gem "webmock" gem "webmock"
+253 -136
View File
@@ -1,84 +1,105 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.0.8.1) abbrev (0.1.2)
actionpack (= 7.0.8.1) actioncable (7.1.6)
activesupport (= 7.0.8.1) actionpack (= 7.1.6)
activesupport (= 7.1.6)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.1) zeitwerk (~> 2.6)
actionpack (= 7.0.8.1) actionmailbox (7.1.6)
activejob (= 7.0.8.1) actionpack (= 7.1.6)
activerecord (= 7.0.8.1) activejob (= 7.1.6)
activestorage (= 7.0.8.1) activerecord (= 7.1.6)
activesupport (= 7.0.8.1) activestorage (= 7.1.6)
activesupport (= 7.1.6)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.0.8.1) actionmailer (7.1.6)
actionpack (= 7.0.8.1) actionpack (= 7.1.6)
actionview (= 7.0.8.1) actionview (= 7.1.6)
activejob (= 7.0.8.1) activejob (= 7.1.6)
activesupport (= 7.0.8.1) activesupport (= 7.1.6)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.2)
actionpack (7.0.8.1) actionpack (7.1.6)
actionview (= 7.0.8.1) actionview (= 7.1.6)
activesupport (= 7.0.8.1) activesupport (= 7.1.6)
rack (~> 2.0, >= 2.2.4) cgi
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.6)
actiontext (7.0.8.1) actiontext (7.1.6)
actionpack (= 7.0.8.1) actionpack (= 7.1.6)
activerecord (= 7.0.8.1) activerecord (= 7.1.6)
activestorage (= 7.0.8.1) activestorage (= 7.1.6)
activesupport (= 7.0.8.1) activesupport (= 7.1.6)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.0.8.1) actionview (7.1.6)
activesupport (= 7.0.8.1) activesupport (= 7.1.6)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) cgi
rails-dom-testing (~> 2.0) erubi (~> 1.11)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-dom-testing (~> 2.2)
activejob (7.0.8.1) rails-html-sanitizer (~> 1.6)
activesupport (= 7.0.8.1) activejob (7.1.6)
activesupport (= 7.1.6)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.0.8.1) activemodel (7.1.6)
activesupport (= 7.0.8.1) activesupport (= 7.1.6)
activerecord (7.0.8.1) activerecord (7.1.6)
activemodel (= 7.0.8.1) activemodel (= 7.1.6)
activesupport (= 7.0.8.1) activesupport (= 7.1.6)
activestorage (7.0.8.1) timeout (>= 0.4.0)
actionpack (= 7.0.8.1) activestorage (7.1.6)
activejob (= 7.0.8.1) actionpack (= 7.1.6)
activerecord (= 7.0.8.1) activejob (= 7.1.6)
activesupport (= 7.0.8.1) activerecord (= 7.1.6)
activesupport (= 7.1.6)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) activesupport (7.1.6)
activesupport (7.0.8.1) base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
mutex_m
securerandom (>= 0.3)
tzinfo (~> 2.0) tzinfo (~> 2.0)
addressable (2.8.6) addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
aes_key_wrap (1.1.0)
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
ast (2.4.2) ast (2.4.2)
authie (4.1.3) attr_required (1.0.2)
activerecord (>= 6.1, < 8.0) authie (5.0.0)
activerecord (>= 6.1, < 9.0)
autoprefixer-rails (10.4.13.0) autoprefixer-rails (10.4.13.0)
execjs (~> 2) execjs (~> 2)
base64 (0.3.0)
bcrypt (3.1.20) bcrypt (3.1.20)
bigdecimal (3.1.6) benchmark (0.5.0)
builder (3.2.4) bigdecimal (4.1.2)
bindata (2.5.0)
builder (3.3.0)
cgi (0.5.1)
chronic (0.10.2) chronic (0.10.2)
coffee-rails (5.0.0) coffee-rails (5.0.0)
coffee-script (>= 2.2.0) coffee-script (>= 2.2.0)
@@ -87,37 +108,42 @@ GEM
coffee-script-source coffee-script-source
execjs execjs
coffee-script-source (1.12.2) coffee-script-source (1.12.2)
concurrent-ruby (1.2.3) concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
crass (1.0.6) crass (1.0.6)
database_cleaner (2.0.2) database_cleaner-active_record (2.2.2)
database_cleaner-active_record (>= 2, < 3)
database_cleaner-active_record (2.1.0)
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
date (3.3.4) date (3.5.1)
deep_merge (1.2.2) diff-lcs (1.6.2)
diff-lcs (1.5.0)
domain_name (0.6.20240107) domain_name (0.6.20240107)
dotenv (3.0.2) dotenv (3.0.2)
dynamic_form (1.3.1) drb (2.2.3)
actionview (> 5.2.0) dynamic_form (1.2.0)
activemodel (> 5.2.0) email_validator (2.2.4)
encrypto_signo (1.0.0) activemodel
erubi (1.12.0) erb (6.0.4)
erubi (1.13.1)
execjs (2.7.0) execjs (2.7.0)
factory_bot (6.4.6) factory_bot (6.4.6)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
factory_bot_rails (6.4.3) factory_bot_rails (6.4.3)
factory_bot (~> 6.4) factory_bot (~> 6.4)
railties (>= 5.0.0) railties (>= 5.0.0)
faraday (2.9.0)
faraday-net_http (>= 2.0, < 3.2)
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-net_http (3.1.0)
net-http
ffi (1.15.5) ffi (1.15.5)
gelf (3.1.0) gelf (3.1.0)
json json
globalid (1.2.1) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
haml (6.3.0) haml (6.3.0)
temple (>= 0.8.2) temple (>= 0.8.2)
@@ -126,13 +152,28 @@ GEM
hashdiff (1.1.0) hashdiff (1.1.0)
hashie (5.0.0) hashie (5.0.0)
highline (2.1.0) highline (2.1.0)
i18n (1.14.1) i18n (1.14.8)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
io-console (0.8.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jquery-rails (4.5.1) jquery-rails (4.5.1)
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.7.1) json (2.7.1)
json-jwt (1.16.6)
activesupport (>= 4.2)
aes_key_wrap
base64
bindata
faraday (~> 2.0)
faraday-follow_redirects
jwt (2.8.1)
base64
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2) kaminari-actionview (= 1.2.2)
@@ -151,106 +192,160 @@ GEM
rouge (>= 3.30, < 5.0) rouge (>= 3.30, < 5.0)
konfig-config (3.0.0) konfig-config (3.0.0)
hashie hashie
loofah (2.22.0) logger (1.7.0)
loofah (2.25.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.9.0)
logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.0.2) marcel (1.2.1)
method_source (1.0.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.5) minitest (6.0.6)
minitest (5.22.2) drb (~> 2.0)
moonrope (2.0.2) prism (~> 1.5)
deep_merge (~> 1.0) mutex_m (0.3.0)
json
rack (>= 1.4)
mysql2 (0.5.6) mysql2 (0.5.6)
net-imap (0.4.10) net-http (0.4.1)
uri
net-imap (0.6.4)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.2) net-protocol (0.2.2)
timeout timeout
net-smtp (0.4.0.1) net-smtp (0.5.1)
net-protocol net-protocol
nifty-utils (1.1.7) nifty-utils (1.1.7)
nilify_blanks (1.4.0) nilify_blanks (1.4.0)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
activesupport (>= 4.0.0) activesupport (>= 4.0.0)
nio4r (2.7.0) nio4r (2.7.5)
nokogiri (1.16.2) nokogiri (1.19.3-aarch64-linux-gnu)
mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.2-aarch64-linux) nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.2-arm64-darwin) nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.2-x86_64-darwin) nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.16.2-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
omniauth (2.1.2)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth_openid_connect (0.7.1)
omniauth (>= 1.9, < 3)
openid_connect (~> 2.2)
openid_connect (2.3.0)
activemodel
attr_required (>= 1.0.0)
email_validator
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 (1.22.1) parallel (1.22.1)
parser (3.2.1.1) parser (3.2.1.1)
ast (~> 2.4.1) ast (~> 2.4.1)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
prometheus-client (4.2.2) prometheus-client (4.2.2)
psych (5.4.0)
date
stringio
public_suffix (5.0.4) public_suffix (5.0.4)
puma (6.4.2) puma (7.0.4)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.7.3) racc (1.8.1)
rack (2.2.8.1) rack (3.2.6)
rack-test (2.1.0) rack-oauth2 (2.2.1)
activesupport
attr_required
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (4.1.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) rack (>= 1.3)
rails (7.0.8.1) rackup (2.3.1)
actioncable (= 7.0.8.1) rack (>= 3)
actionmailbox (= 7.0.8.1) rails (7.1.6)
actionmailer (= 7.0.8.1) actioncable (= 7.1.6)
actionpack (= 7.0.8.1) actionmailbox (= 7.1.6)
actiontext (= 7.0.8.1) actionmailer (= 7.1.6)
actionview (= 7.0.8.1) actionpack (= 7.1.6)
activejob (= 7.0.8.1) actiontext (= 7.1.6)
activemodel (= 7.0.8.1) actionview (= 7.1.6)
activerecord (= 7.0.8.1) activejob (= 7.1.6)
activestorage (= 7.0.8.1) activemodel (= 7.1.6)
activesupport (= 7.0.8.1) activerecord (= 7.1.6)
activestorage (= 7.1.6)
activesupport (= 7.1.6)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.0.8.1) railties (= 7.1.6)
rails-dom-testing (2.2.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.7.0)
loofah (~> 2.21) loofah (~> 2.25)
nokogiri (~> 1.14) 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.0.8.1) railties (7.1.6)
actionpack (= 7.0.8.1) actionpack (= 7.1.6)
activesupport (= 7.0.8.1) activesupport (= 7.1.6)
method_source cgi
irb
rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.5) tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.1.0) rake (13.4.2)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
regexp_parser (2.7.0) regexp_parser (2.7.0)
resolv (0.3.0) reline (0.6.3)
rexml (3.2.5) io-console (~> 0.5)
resolv (0.6.2)
rexml (3.4.4)
rouge (4.2.0) rouge (4.2.0)
rspec (3.12.0) rspec (3.13.1)
rspec-core (~> 3.12.0) rspec-core (~> 3.13.0)
rspec-expectations (~> 3.12.0) rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.12.0) rspec-mocks (~> 3.13.0)
rspec-core (3.12.1) rspec-core (3.13.5)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-expectations (3.12.2) rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-mocks (3.12.4) rspec-mocks (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-rails (6.1.1) rspec-rails (6.1.1)
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
@@ -259,7 +354,7 @@ GEM
rspec-expectations (~> 3.12) rspec-expectations (~> 3.12)
rspec-mocks (~> 3.12) rspec-mocks (~> 3.12)
rspec-support (~> 3.12) rspec-support (~> 3.12)
rspec-support (3.12.0) rspec-support (3.13.6)
rubocop (1.48.1) rubocop (1.48.1)
json (~> 2.3) json (~> 2.3)
parallel (~> 1.10) parallel (~> 1.10)
@@ -288,6 +383,7 @@ GEM
sprockets-rails sprockets-rails
tilt tilt
secure_headers (6.5.0) secure_headers (6.5.0)
securerandom (0.4.1)
sentry-rails (5.16.1) sentry-rails (5.16.1)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby (~> 5.16.1) sentry-ruby (~> 5.16.1)
@@ -302,11 +398,18 @@ GEM
actionpack (>= 5.2) actionpack (>= 5.2)
activesupport (>= 5.2) activesupport (>= 5.2)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
stringio (3.2.0)
swd (2.0.3)
activesupport (>= 3)
attr_required (>= 0.0.5)
faraday (~> 2.0)
faraday-follow_redirects
temple (0.10.3) temple (0.10.3)
thor (1.3.0) thor (1.5.0)
tilt (2.3.0) tilt (2.3.0)
timecop (0.9.8) timecop (0.9.8)
timeout (0.4.1) timeout (0.6.1)
tsort (0.2.0)
turbolinks (5.2.1) turbolinks (5.2.1)
turbolinks-source (~> 5.2) turbolinks-source (~> 5.2)
turbolinks-source (5.2.0) turbolinks-source (5.2.0)
@@ -315,35 +418,43 @@ GEM
uglifier (4.2.0) uglifier (4.2.0)
execjs (>= 0.3.0, < 3) execjs (>= 0.3.0, < 3)
unicode-display_width (2.4.2) unicode-display_width (2.4.2)
uri (1.0.3)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
webfinger (2.1.3)
activesupport
faraday (~> 2.0)
faraday-follow_redirects
webmock (3.20.0) webmock (3.20.0)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.1) webrick (1.9.1)
websocket-driver (0.7.6) websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
zeitwerk (2.6.13) zeitwerk (2.8.2)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
arm64-darwin arm64-darwin
ruby
x86_64-darwin x86_64-darwin
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
abbrev
annotate annotate
authie authie
autoprefixer-rails autoprefixer-rails
bcrypt bcrypt
chronic chronic
coffee-rails (~> 5.0) coffee-rails (~> 5.0)
database_cleaner database_cleaner-active_record
domain_name domain_name
dotenv dotenv
dynamic_form dynamic_form
encrypto_signo
execjs (~> 2.7, < 2.8) execjs (~> 2.7, < 2.8)
factory_bot_rails factory_bot_rails
gelf gelf
@@ -351,18 +462,24 @@ DEPENDENCIES
hashie hashie
highline highline
jquery-rails jquery-rails
jwt
kaminari kaminari
klogger-logger klogger-logger
konfig-config (~> 3.0) konfig-config (~> 3.0)
logger
mail mail
moonrope mutex_m
mysql2 mysql2
nifty-utils nifty-utils
nilify_blanks nilify_blanks
nio4r nio4r
omniauth-rails_csrf_protection
omniauth_openid_connect
ostruct
prometheus-client prometheus-client
puma puma
rails (= 7.0.8.1) rackup
rails (= 7.1.6)
resolv resolv
rspec rspec
rspec-rails rspec-rails
@@ -379,4 +496,4 @@ DEPENDENCIES
webrick webrick
BUNDLED WITH BUNDLED WITH
2.5.6 2.7.2
+2 -2
View File
@@ -11,5 +11,5 @@ We only support updates to the 3.x versions of Postal.
## Reporting a Vulnerability ## Reporting a Vulnerability
If you discover a vulnerability in Postal, please do not post an issue on GitHub. Instead you should send an If you discover a vulnerability in Postal, please do not post an issue on GitHub. Please,
e-mail to security@postalserver.io with details. We will get back to you directly. instead, [create a new security advisory through GitHub](https://github.com/postalserver/postal/security/advisories/new).
-31
View File
@@ -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
-112
View File
@@ -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
-12
View File
@@ -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
-68
View File
@@ -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
@@ -1,22 +1,32 @@
.loginForm { .loginForm {}
}
.loginForm__input { .loginForm__input {
margin-bottom:15px; margin-bottom: 15px;
} }
.loginForm__submit { .loginForm__submit {
display:flex; display: flex;
justify-content:space-between; justify-content: space-between;
align-items:center; align-items: center;
} }
.loginForm__links { .loginForm__links {
font-size:12px; font-size: 12px;
color:#999; color: #999;
text-decoration: underline; 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 { .userList {
border-radius:4px; border-radius: 4px;
color:$darkBlue; color: $darkBlue;
overflow:hidden; overflow: hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2); box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
} }
.userList__item { .userList__item {
display:block; display: block;
background:#fff; background: #fff;
padding:15px; padding: 15px;
display:flex; display: flex;
align-items: center; align-items: center;
} }
.userList__item:nth-child(even) { .userList__item:nth-child(even) {
background:none; background: none;
} }
.userList__item + .userList__item { .userList__item+.userList__item {
border-top:1px solid lighten(#ccd4e0, 10%); 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__details { .userList__details {
flex: 1 1 auto; flex: 1 1 auto;
margin:0 25px; margin: 0 0;
} }
.userList__actions { .userList__actions {
flex: 0 0 auto; flex: 0 0 auto;
width:180px; width: 120px;
font-size:12px; font-size: 12px;
line-height:1.5; line-height: 1.5;
color:#999; color: #999;
text-decoration: underline;
a {
text-decoration: underline;
}
} }
.userList__name { .userList__name {
font-weight:600; font-weight: 600;
font-size:16px; font-size: 16px;
margin-bottom:3px; margin-bottom: 3px;
} }
.userList__owner { .userList__owner {
vertical-align:2px; vertical-align: 2px;
margin-left:5px; margin-left: 5px;
background-color:$orange; background-color: $orange;
} }
.userList__pending { .userList__pending {
vertical-align:2px; vertical-align: 2px;
margin-left:5px; margin-left: 5px;
background-color:#ccc; background-color: #ccc;
} }
.userList__admin { .userList__tag {
vertical-align:2px; vertical-align: 2px;
margin-left:5px; margin-left: 3px;
background-color:$blue;
} }
.userList__revoke { .userList__revoke {
color:$red; color: $red;
} }
@@ -1,98 +1,117 @@
.button { .button {
display:inline-block; display: inline-block;
font:inherit; font: inherit;
border-radius:4px; border-radius: 4px;
appearance:none; appearance: none;
background:$blue; background: $blue;
color:#fff; color: #fff;
font-size:14px !important; font-size: 14px !important;
margin:0; margin: 0;
vertical-align:top; vertical-align: top;
padding:6px 15px; padding: 6px 15px;
border:2px solid transparent; border: 2px solid transparent;
border-bottom:2px solid darken($blue, 20%); border-bottom: 2px solid darken($blue, 20%);
&:active { &:active {
background-color:darken($blue, 15%); background-color: darken($blue, 15%);
} }
&:focus { &:focus {
border-color:darken($blue, 15%); border-color: darken($blue, 15%);
background-color:lighten($blue, 5%); background-color: lighten($blue, 5%);
} }
&.is-spinning { &.is-spinning {
color:transparent; color: transparent;
background-repeat:no-repeat; background-repeat: no-repeat;
background-position: center center; background-position: center center;
background-size:25px; background-size: 25px;
background-image:image-url('button-spinner.gif'); background-image: image-url('button-spinner.gif');
} }
} }
.button--small { .button--small {
font-size:12px !important; font-size: 12px !important;
padding:3px 10px; padding: 3px 10px;
border-width:1px; border-width: 1px;
} }
.button--positive { .button--positive {
background-color:$green; background-color: $green;
border-bottom-color:darken($green, 15%); border-bottom-color: darken($green, 15%);
&:active { &:active {
background-color:darken($green, 15%); background-color: darken($green, 15%);
} }
&:focus { &:focus {
border-color:darken($green, 15%); border-color: darken($green, 15%);
background-color:lighten($green, 5%); background-color: lighten($green, 5%);
} }
&.is-spinning { &.is-spinning {
background-image:image-url('button-spinner-positive.gif'); background-image: image-url('button-spinner-positive.gif');
} }
} }
.button--neutral { .button--neutral {
background-color:#ccc; background-color: #ccc;
border-bottom-color:darken(#ccc, 15%); border-bottom-color: darken(#ccc, 15%);
&:active { &:active {
background-color:darken(#ccc, 15%); background-color: darken(#ccc, 15%);
} }
&:focus { &:focus {
border-color:darken(#ccc, 15%); border-color: darken(#ccc, 15%);
background-color:lighten(#ccc, 5%); background-color: lighten(#ccc, 5%);
} }
&.is-spinning { &.is-spinning {
background-image:image-url('button-spinner-neutral.gif'); background-image: image-url('button-spinner-neutral.gif');
} }
} }
.button--danger { .button--danger {
background-color:$red; background-color: $red;
border-bottom-color:darken($red, 15%); border-bottom-color: darken($red, 15%);
&:active { &:active {
background-color:darken($red, 15%); background-color: darken($red, 15%);
} }
&:focus { &:focus {
border-color:darken($red, 15%); border-color: darken($red, 15%);
background-color:lighten($red, 5%); background-color: lighten($red, 5%);
} }
&.is-spinning { &.is-spinning {
background-image:image-url('button-spinner-danger.gif'); background-image: image-url('button-spinner-danger.gif');
} }
} }
.button--dark { .button--dark {
background-color:$darkBlue; background-color: $darkBlue;
border-bottom-color:darken($darkBlue, 15%); border-bottom-color: darken($darkBlue, 15%);
&:active { &:active {
background-color:darken($darkBlue, 15%); background-color: darken($darkBlue, 15%);
} }
&:focus { &:focus {
border-color:darken($darkBlue, 15%); border-color: darken($darkBlue, 15%);
background-color:lighten($darkBlue, 5%); background-color: lighten($darkBlue, 5%);
} }
&.is-spinning { &.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 { .label {
display:inline-block; display: inline-block;
background:#000; background: #000;
color:#fff; color: #fff;
font-size:9px; font-size: 9px;
text-transform: uppercase; text-transform: uppercase;
border-radius:40px; border-radius: 40px;
padding:2px 6px; padding: 2px 6px;
line-height:0.9; line-height: 0.9;
} }
.label--green { .label--green {
background-color:$green; background-color: $green;
} }
.label--red { .label--red {
background-color:$red; background-color: $red;
} }
.label--orange { .label--orange {
background-color:$orange; background-color: $orange;
} }
.label--blue { .label--blue {
background-color:$blue; background-color: $blue;
} }
.label--grey { .label--grey {
background-color:#999; background-color: #999;
} }
.label--turquoise { .label--turquoise {
background-color:$blue; background-color: $blue;
} }
.label--purple { .label--purple {
background-color:$purple; background-color: $purple;
} }
.label--large { .label--large {
font-size:11px; font-size: 11px;
padding:4px 10px; padding: 4px 10px;
} }
.label--serverStatus-live { .label--serverStatus-live {
background-color:$green; background-color: $green;
} }
.label--serverStatus-development { .label--serverStatus-development {
background-color:#636363; background-color: #636363;
} }
.label--serverStatus-suspended { .label--serverStatus-suspended {
background-color:$red; background-color: $red;
} }
.label--messageStatus-pending { .label--messageStatus-pending {
background-color:$subBlue; background-color: $subBlue;
} }
.label--messageStatus-held { .label--messageStatus-held {
background-color:#aaa; background-color: #aaa;
} }
.label--messageStatus-processed { .label--messageStatus-processed {
background-color:$green; background-color: $green;
} }
.label--messageStatus-sent { .label--messageStatus-sent {
background-color:$green; background-color: $green;
} }
.label--messageStatus-hard_fail { .label--messageStatus-hard_fail {
background-color:$red; background-color: $red;
} }
.label--messageStatus-soft_fail { .label--messageStatus-soft_fail {
background-color:$orange; background-color: $orange;
} }
.label--messageStatus-bounced { .label--messageStatus-bounced {
background-color:$red; background-color: $red;
} }
.label--messageStatus-hold_cancelled { .label--messageStatus-hold_cancelled {
background-color:#ccc; background-color: #ccc;
} }
.label--credentialType-api { .label--credentialType-api {
background-color:$blue; background-color: $blue;
} }
.label--credentialType-smtp { .label--credentialType-smtp {
background-color:$turquoise; background-color: $turquoise;
} }
.label--credentialType-smtp_ip { .label--credentialType-smtp_ip {
background-color:$orange; background-color: $orange;
} }
.label--spamStatus-not_checked { .label--spamStatus-not_checked {
background:#aaa; background: #aaa;
} }
.label--spamStatus-spam { .label--spamStatus-spam {
background:$orange; background: $orange;
} }
.label--spamStatus-not_spam { .label--spamStatus-not_spam {
background:$turquoise; background: $turquoise;
} }
.label--http-status-2 { .label--http-status-2 {
background-color:$green; background-color: $green;
} }
.label--http-status-3 { .label--http-status-3 {
background-color:$orange; background-color: $orange;
} }
.label--http-status-4, .label--http-status-4,
.label--http-status-5 { .label--http-status-5 {
background-color:$red; background-color: $red;
} }
.domainList__ssl { .domainList__ssl {
color:$green; color: $green;
&:hover { &:hover {
text-decoration:underline; text-decoration: underline;
} }
} }
.domainList__ssl--disabled { .domainList__ssl--disabled {
color:#999; color: #999;
} }
+5 -2
View File
@@ -62,10 +62,13 @@ class ApplicationController < ActionController::Base
end end
def url_with_return_to(url) 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) url_for(url)
else else
params[:return_to] return_to
end end
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
+12
View File
@@ -89,6 +89,18 @@ class MessagesController < ApplicationController
end end
def html_raw 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 render html: @message.html_body_without_tracking_image.html_safe
end end
+4 -3
View File
@@ -43,10 +43,11 @@ class OrganizationsController < ApplicationController
end end
def destroy 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| 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." } alert_text = "The text you entered does not match the organization name. Please check and try again."
wants.json { render json: { alert: "The password you entered was invalid. Please check and try again." } } wants.html { redirect_to organization_delete_path(@organization), alert: alert_text }
wants.json { render json: { alert: alert_text } }
end end
return return
end end
+16 -10
View File
@@ -45,8 +45,16 @@ class ServersController < ApplicationController
extra_params = [:spam_threshold, :spam_failure_threshold, :postmaster_address] extra_params = [:spam_threshold, :spam_failure_threshold, :postmaster_address]
if current_user.admin? if current_user.admin?
extra_params += [:send_limit, :allow_sender, :privacy_mode, :log_smtp_data, :outbound_spam_threshold, extra_params += [
:message_retention_days, :raw_message_retention_days, :raw_message_retention_size] :send_limit,
:allow_sender,
:privacy_mode,
:log_smtp_data,
:outbound_spam_threshold,
:message_retention_days,
:raw_message_retention_days,
:raw_message_retention_size,
]
end end
if @server.update(safe_params(*extra_params)) if @server.update(safe_params(*extra_params))
@@ -57,23 +65,21 @@ class ServersController < ApplicationController
end end
def destroy 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| respond_to do |wants|
wants.html do alert_text = "The text you entered does not match the server name. Please check and try again."
redirect_to [:delete, organization, @server], alert: "The password you entered was not valid. Please check and try again." wants.html { redirect_to organization_delete_path(@organization), alert: alert_text }
end wants.json { render json: { alert: alert_text } }
wants.json do
render json: { alert: "The password you entere was invalid. Please check and try again" }
end
end end
return return
end end
@server.soft_destroy @server.soft_destroy
redirect_to_with_json organization_root_path(organization), notice: "#{@server.name} has been deleted successfully" redirect_to_with_json organization_root_path(organization), notice: "#{@server.name} has been deleted successfully"
end end
def queue 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 @messages_with_message = @messages.include_message
end end
+41 -6
View File
@@ -4,7 +4,8 @@ class SessionsController < ApplicationController
layout "sub" 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 def create
login(User.authenticate(params[:email_address], params[:password])) login(User.authenticate(params[:email_address], params[:password]))
@@ -29,12 +30,16 @@ class SessionsController < ApplicationController
def begin_password_reset def begin_password_reset
return unless request.post? return unless request.post?
if user = User.where(email_address: params[:email_address]).first user_scope = Postal::Config.oidc.enabled? ? User.with_password : User
user.begin_password_reset(params[:return_to]) user = user_scope.find_by(email_address: params[:email_address])
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 if user.nil?
redirect_to login_reset_path(return_to: params[:return_to]), alert: "No user exists with that e-mail address. Please check and try again." 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 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 end
def finish_password_reset def finish_password_reset
@@ -49,6 +54,7 @@ class SessionsController < ApplicationController
flash.now[:alert] = "You must enter a new password" flash.now[:alert] = "You must enter a new password"
return return
end end
@user.password = params[:password] @user.password = params[:password]
@user.password_confirmation = params[:password_confirmation] @user.password_confirmation = params[:password_confirmation]
return unless @user.save return unless @user.save
@@ -61,4 +67,33 @@ class SessionsController < ApplicationController
render plain: "ip: #{request.ip} remote ip: #{request.remote_ip}" render plain: "ip: #{request.ip} remote ip: #{request.remote_ip}"
end 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 end
+17 -12
View File
@@ -30,23 +30,28 @@ class UserController < ApplicationController
def update def update
@user = User.find(current_user.id) @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]) if @user.password? && Postal::Config.oidc.local_authentication_enabled?
@password_correct = true safe_params += [:password, :password_confirmation]
else if @user.authenticate_with_previous_password_first(params[:password])
respond_to do |wants| @password_correct = true
wants.html do else
flash.now[:alert] = "The current password you have entered is incorrect. Please check and try again." respond_to do |wants|
render "edit" wants.html do
end flash.now[:alert] = "The current password you have entered is incorrect. Please check and try again."
wants.json do render "edit"
render json: { alert: "The current password you've entered is incorrect. Please check and try again" } end
wants.json do
render json: { alert: "The current password you've entered is incorrect. Please check and try again" }
end
end end
return
end end
return
end end
@user.attributes = params.require(:user).permit(safe_params)
if @user.save if @user.save
redirect_to_with_json settings_path, notice: "Your settings have been updated successfully." redirect_to_with_json settings_path, notice: "Your settings have been updated successfully."
else else
+15
View File
@@ -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
+14 -6
View File
@@ -3,6 +3,7 @@
module ApplicationHelper module ApplicationHelper
def format_delivery_details(server, text) def format_delivery_details(server, text)
text = h(text)
text.gsub!(/<msg:(\d+)>/) do text.gsub!(/<msg:(\d+)>/) do
id = ::Regexp.last_match(1).to_i id = ::Regexp.last_match(1).to_i
link_to("message ##{id}", organization_server_message_path(server.organization, server, id), class: "u-link") 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'>" s << "<optgroup label='Server Domains'>"
server_domains.each do |domain| server_domains.each do |domain|
selected = domain == selected_domain ? "selected='selected'" : "" 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 end
s << "</optgroup>" s << "</optgroup>"
end end
@@ -42,7 +43,7 @@ module ApplicationHelper
s << "<optgroup label='Organization Domains'>" s << "<optgroup label='Organization Domains'>"
organization_domains.each do |domain| organization_domains.each do |domain|
selected = domain == selected_domain ? "selected='selected'" : "" 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 end
s << "</optgroup>" s << "</optgroup>"
end end
@@ -59,7 +60,7 @@ module ApplicationHelper
http_endpoints.each do |endpoint| http_endpoints.each do |endpoint|
value = "#{endpoint.class}##{endpoint.uuid}" value = "#{endpoint.class}##{endpoint.uuid}"
selected = value == selected_value ? "selected='selected'" : "" 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 end
s << "</optgroup>" s << "</optgroup>"
end end
@@ -70,7 +71,7 @@ module ApplicationHelper
smtp_endpoints.each do |endpoint| smtp_endpoints.each do |endpoint|
value = "#{endpoint.class}##{endpoint.uuid}" value = "#{endpoint.class}##{endpoint.uuid}"
selected = value == selected_value ? "selected='selected'" : "" 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 end
s << "</optgroup>" s << "</optgroup>"
end end
@@ -81,7 +82,7 @@ module ApplicationHelper
address_endpoints.each do |endpoint| address_endpoints.each do |endpoint|
value = "#{endpoint.class}##{endpoint.uuid}" value = "#{endpoint.class}##{endpoint.uuid}"
selected = value == selected_value ? "selected='selected'" : "" 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 end
s << "</optgroup>" s << "</optgroup>"
end end
@@ -93,11 +94,18 @@ module ApplicationHelper
selected = (selected_value == mode ? "selected='selected'" : "") selected = (selected_value == mode ? "selected='selected'" : "")
text = t("route_modes.#{mode.underscore}") text = t("route_modes.#{mode.underscore}")
s << "<option value='#{mode}' #{selected}>#{text}</option>" s << "<option value='#{h(mode)}' #{selected}>#{h(text)}</option>"
end end
s << "</optgroup>" s << "</optgroup>"
end end
end.html_safe end.html_safe
end end
def postal_version_string
string = Postal.version
string += " (#{Postal.branch})" if Postal.branch &&
Postal.branch != "main"
string
end
end end
+1 -1
View File
@@ -9,7 +9,7 @@ class DKIMHeader
@dkim_identifier = domain.dkim_identifier @dkim_identifier = domain.dkim_identifier
else else
@domain_name = Postal::Config.dns.return_path_domain @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 @dkim_identifier = Postal::Config.dns.dkim_identifier
end end
@domain = domain @domain = domain
+5 -3
View File
@@ -111,9 +111,11 @@ class DNSResolver
def dns(raise_timeout_errors: false) def dns(raise_timeout_errors: false)
Resolv::DNS.open(nameserver: @nameservers, Resolv::DNS.open(nameserver: @nameservers,
raise_timeout_errors: raise_timeout_errors) do |dns| raise_timeout_errors: raise_timeout_errors) do |dns|
dns.timeouts = [Postal::Config.dns.timeout, dns.timeouts = [
Postal::Config.dns.timeout / 2, Postal::Config.dns.timeout,
Postal::Config.dns.timeout / 2] Postal::Config.dns.timeout / 2,
Postal::Config.dns.timeout / 2,
]
yield dns yield dns
end end
end end
+15 -3
View File
@@ -3,6 +3,8 @@
module MessageDequeuer module MessageDequeuer
class InitialProcessor < Base class InitialProcessor < Base
include HasPrometheusMetrics
attr_accessor :send_result attr_accessor :send_result
def process def process
@@ -10,6 +12,7 @@ module MessageDequeuer
logger.info "starting message unqueue" logger.info "starting message unqueue"
begin begin
catch_stops do catch_stops do
increment_dequeue_metric
check_message_exists check_message_exists
check_message_is_ready check_message_is_ready
find_other_messages_for_batch find_other_messages_for_batch
@@ -17,7 +20,7 @@ module MessageDequeuer
# Process the original message and then all of those # Process the original message and then all of those
# found for batching. # found for batching.
process_message(@queued_message) process_message(@queued_message)
@other_messages.each { |message| process_message(message) } @other_messages&.each { |message| process_message(message) }
end end
ensure ensure
@state.finished @state.finished
@@ -28,9 +31,16 @@ module MessageDequeuer
private 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 def check_message_exists
@queued_message.message return if @queued_message.message
rescue Postal::MessageDB::Message::NotFound
log "unqueue because backend message has been removed." log "unqueue because backend message has been removed."
remove_from_queue remove_from_queue
stop_processing stop_processing
@@ -45,6 +55,8 @@ module MessageDequeuer
end end
def find_other_messages_for_batch def find_other_messages_for_batch
return unless Postal::Config.postal.batch_queued_messages?
@other_messages = @queued_message.batchable_messages(100) @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 log "found #{@other_messages.size} associated messages to process at the same time", batch_key: @queued_message.batch_key
rescue StandardError rescue StandardError
@@ -31,8 +31,8 @@ module MessageDequeuer
private private
def check_message_exists def check_message_exists
queued_message.message return if queued_message.message
rescue Postal::MessageDB::Message::NotFound
log "unqueueing because backend message has been removed" log "unqueueing because backend message has been removed"
remove_from_queue remove_from_queue
stop_processing stop_processing
+1 -1
View File
@@ -13,7 +13,7 @@ class ReplySeparator
/^>*\s*On.{10,200}wrote:\s*$.*/m, /^>*\s*On.{10,200}wrote:\s*$.*/m,
/^>*\s*Sent from my.*/m, /^>*\s*Sent from my.*/m,
/^>*\s*=== Please reply above this line ===.*/m, /^>*\s*=== Please reply above this line ===.*/m,
/(^>.*\n?){10,}/ /(^>.*\n?){10,}/,
].freeze ].freeze
def self.separate(text) def self.separate(text)
+10 -8
View File
@@ -116,18 +116,19 @@ module SMTPServer
private private
def proxy(data) def proxy(data)
# inet-protocol, client-ip, proxy-ip, client-port, proxy-port
if m = data.match(/\APROXY (.+) (.+) (.+) (.+) (.+)\z/) if m = data.match(/\APROXY (.+) (.+) (.+) (.+) (.+)\z/)
@ip_address = m[2] @ip_address = m[2]
check_ip_address check_ip_address
@state = :welcome @state = :welcome
logger&.debug "\e[35mClient identified as #{@ip_address}\e[0m" logger&.debug "\e[35mClient identified as #{@ip_address}\e[0m"
increment_command_count("PROXY") increment_command_count("PROXY")
"220 #{Postal::Config.postal.smtp_hostname} ESMTP Postal/#{id}" return "220 #{Postal::Config.postal.smtp_hostname} ESMTP Postal/#{trace_id}"
else
@finished = true
increment_error_count("proxy-error")
"502 Proxy Error"
end end
@finished = true
increment_error_count("proxy-error")
"502 Proxy Error"
end end
def quit def quit
@@ -155,7 +156,7 @@ module SMTPServer
[ [
"250-My capabilities are", "250-My capabilities are",
Postal::Config.smtp_server.tls_enabled? && !@tls ? "250-STARTTLS" : nil, Postal::Config.smtp_server.tls_enabled? && !@tls ? "250-STARTTLS" : nil,
"250 AUTH CRAM-MD5 PLAIN LOGIN" "250 AUTH CRAM-MD5 PLAIN LOGIN",
].compact ].compact
end end
@@ -252,7 +253,7 @@ module SMTPServer
org_permlink, server_permalink = username.split(/[\/_]/, 2) org_permlink, server_permalink = username.split(/[\/_]/, 2)
server = ::Server.includes(:organization).where(organizations: { permalink: org_permlink }, permalink: server_permalink).first server = ::Server.includes(:organization).where(organizations: { permalink: org_permlink }, permalink: server_permalink).first
if server.nil? 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") increment_error_count("invalid-credentials")
next "535 Denied" next "535 Denied"
end end
@@ -264,12 +265,13 @@ module SMTPServer
@credential = credential @credential = credential
@credential.use @credential.use
logger&.debug "Authenticated with with credential #{credential.id}"
grant = "235 Granted for #{credential.server.organization.permalink}/#{credential.server.permalink}" grant = "235 Granted for #{credential.server.organization.permalink}/#{credential.server.permalink}"
break break
end end
if grant.nil? if grant.nil?
logger&.warn "Authentication failure for #{@ip_address}" logger&.warn "Authentication failure for #{@ip_address} (invalid credential)"
increment_error_count("invalid-credentials") increment_error_count("invalid-credentials")
next "535 Denied" next "535 Denied"
end end
+30 -6
View File
@@ -26,7 +26,7 @@ module Worker
# @return [Array<Class>] # @return [Array<Class>]
JOBS = [ JOBS = [
Jobs::ProcessQueuedMessagesJob, Jobs::ProcessQueuedMessagesJob,
Jobs::ProcessWebhookRequestsJob Jobs::ProcessWebhookRequestsJob,
].freeze ].freeze
# An array of tasks that should be processed # An array of tasks that should be processed
@@ -40,11 +40,14 @@ module Worker
ProcessMessageRetentionScheduledTask, ProcessMessageRetentionScheduledTask,
PruneSuppressionListsScheduledTask, PruneSuppressionListsScheduledTask,
PruneWebhookRequestsScheduledTask, PruneWebhookRequestsScheduledTask,
SendNotificationsScheduledTask SendNotificationsScheduledTask,
TidyQueuedMessagesTask,
].freeze ].freeze
# @param [Integer] thread_count The number of worker threads to run in this process # @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 @thread_count = thread_count
@exit_pipe_read, @exit_pipe_write = IO.pipe @exit_pipe_read, @exit_pipe_write = IO.pipe
@work_sleep_time = work_sleep_time @work_sleep_time = work_sleep_time
@@ -57,6 +60,7 @@ module Worker
def run def run
logger.tagged(component: "worker") do logger.tagged(component: "worker") do
setup_traps setup_traps
ensure_connection_pool_size_is_suitable
start_work_threads start_work_threads
start_tasks_thread start_tasks_thread
wait_for_threads wait_for_threads
@@ -93,6 +97,23 @@ module Worker
@exit_pipe_read.wait_readable(wait_time) ? true : false @exit_pipe_read.wait_readable(wait_time) ? true : false
end 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 # Wait for all threads to complete
# #
# @return [void] # @return [void]
@@ -181,7 +202,7 @@ module Worker
logger.info "stopping tasks thread" logger.info "stopping tasks thread"
ActiveRecord::Base.connection_pool.with_connection do ActiveRecord::Base.connection_pool.with_connection do
if WorkerRole.release(:tasks) if WorkerRole.release(:tasks)
logger.info "releasesd tasks role" logger.info "released tasks role"
end end
end end
end end
@@ -277,7 +298,7 @@ module Worker
labels: [:thread, :job] labels: [:thread, :job]
register_prometheus_histogram :postal_worker_job_runtime, 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] labels: [:thread, :job]
register_prometheus_counter :postal_worker_errors, register_prometheus_counter :postal_worker_errors,
@@ -285,8 +306,11 @@ module Worker
labels: [:error] labels: [:error]
register_prometheus_histogram :postal_worker_task_runtime, 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] 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
end end
+17 -2
View File
@@ -5,15 +5,20 @@ module HasAuthentication
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
has_secure_password has_secure_password validations: false
validates :password, length: { minimum: 8, allow_blank: true } 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 before_save :clear_password_reset_token_on_password_change
scope :with_password, -> { where.not(password_digest: nil) }
end end
class_methods do class_methods do
def authenticate(email_address, password) 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, "InvalidEmailAddress" if user.nil?
raise Postal::Errors::AuthenticationError, "InvalidPassword" unless user.authenticate(password) raise Postal::Errors::AuthenticationError, "InvalidPassword" unless user.authenticate(password)
@@ -30,6 +35,10 @@ module HasAuthentication
end end
def begin_password_reset(return_to = nil) 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 = SecureRandom.alphanumeric(24)
self.password_reset_token_valid_until = 1.day.from_now self.password_reset_token_valid_until = 1.day.from_now
save! save!
@@ -45,6 +54,12 @@ module HasAuthentication
self.password_reset_token_valid_until = nil self.password_reset_token_valid_until = nil
end end
def validate_password_presence
return if password_digest.present? || Postal::Config.oidc.enabled?
errors.add :password, :blank
end
end end
# -*- SkipSchemaAnnotations # -*- SkipSchemaAnnotations
+5 -1
View File
@@ -7,7 +7,11 @@ module HasMessage
end end
def message 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 end
def message=(message) def message=(message)
+1 -1
View File
@@ -31,7 +31,7 @@ class Credential < ApplicationRecord
validate :validate_key_cannot_be_changed validate :validate_key_cannot_be_changed
validate :validate_key_for_smtp_ip validate :validate_key_for_smtp_ip
serialize :options, Hash serialize :options, type: Hash
before_validation :generate_key before_validation :generate_key
+16
View File
@@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require "uri"
# == Schema Information # == Schema Information
# #
# Table name: http_endpoints # Table name: http_endpoints
@@ -38,6 +40,7 @@ class HTTPEndpoint < ApplicationRecord
validates :name, presence: true validates :name, presence: true
validates :url, presence: true validates :url, presence: true
validate :url_must_be_http_or_https
validates :encoding, inclusion: { in: ENCODINGS } validates :encoding, inclusion: { in: ENCODINGS }
validates :format, inclusion: { in: FORMATS } validates :format, inclusion: { in: FORMATS }
validates :timeout, numericality: { greater_than_or_equal_to: 5, less_than_or_equal_to: 60 } 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") } routes.each { |r| r.update(endpoint: nil, mode: "Reject") }
end 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 end
+1 -1
View File
@@ -99,7 +99,7 @@ class OutgoingMessagePrototype
{ {
name: attachment[:name], name: attachment[:name],
content_type: attachment[:content_type] || "application/octet-stream", 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
end end
+7 -3
View File
@@ -33,14 +33,14 @@ class QueuedMessage < ApplicationRecord
belongs_to :server belongs_to :server
belongs_to :ip_address, optional: true belongs_to :ip_address, optional: true
belongs_to :user, optional: true
before_create :allocate_ip_address before_create :allocate_ip_address
scope :ready_with_delayed_retry, -> { where("retry_after IS NULL OR retry_after < ?", 30.seconds.ago) } 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 def retry_now
update(retry_after: nil) update!(retry_after: nil)
end end
def send_bounce def send_bounce
@@ -50,7 +50,11 @@ class QueuedMessage < ApplicationRecord
end end
def allocate_ip_address 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 self.ip_address = pool.ip_addresses.select_by_priority
end end
+81 -20
View File
@@ -5,19 +5,21 @@
# Table name: users # Table name: users
# #
# id :integer not null, primary key # id :integer not null, primary key
# uuid :string(255) # admin :boolean default(FALSE)
# first_name :string(255)
# last_name :string(255)
# email_address :string(255) # email_address :string(255)
# password_digest :string(255)
# time_zone :string(255)
# email_verification_token :string(255) # email_verification_token :string(255)
# email_verified_at :datetime # email_verified_at :datetime
# created_at :datetime # first_name :string(255)
# updated_at :datetime # last_name :string(255)
# oidc_issuer :string(255)
# oidc_uid :string(255)
# password_digest :string(255)
# password_reset_token :string(255) # password_reset_token :string(255)
# password_reset_token_valid_until :datetime # password_reset_token_valid_until :datetime
# admin :boolean default(FALSE) # time_zone :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
# #
# Indexes # Indexes
# #
@@ -28,13 +30,11 @@
class User < ApplicationRecord class User < ApplicationRecord
include HasUUID include HasUUID
include HasAuthentication include HasAuthentication
validates :first_name, presence: true validates :first_name, presence: true
validates :last_name, presence: true validates :last_name, presence: true
validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: /@/, allow_blank: 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" } default_value :time_zone, -> { "UTC" }
@@ -53,24 +53,85 @@ class User < ApplicationRecord
"#{first_name} #{last_name}" "#{first_name} #{last_name}"
end end
def password?
password_digest.present?
end
def oidc?
oidc_uid.present?
end
def to_param def to_param
uuid uuid
end 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 def email_tag
"#{name} <#{email_address}>" "#{name} <#{email_address}>"
end end
def self.[](email) class << self
where(email_address: email).first
# 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
end end
+1 -1
View File
@@ -34,7 +34,7 @@ class WebhookRequest < ApplicationRecord
validates :url, presence: true validates :url, presence: true
validates :event, presence: true validates :event, presence: true
serialize :payload, Hash serialize :payload, type: Hash
class << self class << self
@@ -7,7 +7,7 @@ class ApplicationScheduledTask
end end
def call def call
# override me raise NotImplementedError
end end
attr_reader :logger attr_reader :logger
@@ -2,7 +2,7 @@
class ProcessMessageRetentionScheduledTask < ApplicationScheduledTask class ProcessMessageRetentionScheduledTask < ApplicationScheduledTask
def perform def call
Server.all.each do |server| Server.all.each do |server|
if server.raw_message_retention_days if server.raw_message_retention_days
# If the server has a maximum number of retained raw messages, remove any that are older than this # 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
+3 -3
View File
@@ -244,11 +244,11 @@ class SMTPSender < BaseSender
relays = Postal::Config.postal.smtp_relays relays = Postal::Config.postal.smtp_relays
return nil if relays.nil? return nil if relays.nil?
relays.map do |relay| relays = relays.filter_map do |relay|
next unless relay.host.present? next unless relay.host.present?
SMTPClient::Server.new(relay.host, relay.port, ssl_mode: relay.ssl_mode) SMTPClient::Server.new(relay.host, port: relay.port, ssl_mode: relay.ssl_mode)
end.compact end
@smtp_relays = relays.empty? ? nil : relays @smtp_relays = relays.empty? ? nil : relays
end end
+7 -7
View File
@@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require "socket" require "socket"
require "rack/handler/webrick" require "rackup/handler/webrick"
require "prometheus/client/formats/text" require "prometheus/client/formats/text"
class HealthServer class HealthServer
@@ -55,11 +55,11 @@ class HealthServer
port = ENV.fetch("HEALTH_SERVER_PORT", default_port) port = ENV.fetch("HEALTH_SERVER_PORT", default_port)
bind_address = ENV.fetch("HEALTH_SERVER_BIND_ADDRESS", default_bind_address) bind_address = ENV.fetch("HEALTH_SERVER_BIND_ADDRESS", default_bind_address)
Rack::Handler::WEBrick.run(new(**options), Rackup::Handler::WEBrick.run(new(**options),
Port: port, Port: port,
BindAddress: bind_address, BindAddress: bind_address,
AccessLog: [], AccessLog: [],
Logger: LoggerProxy.new) Logger: LoggerProxy.new)
rescue Errno::EADDRINUSE rescue Errno::EADDRINUSE
Postal.logger.info "health server port (#{bind_address}:#{port}) is already " \ Postal.logger.info "health server port (#{bind_address}:#{port}) is already " \
"in use, not starting health server" "in use, not starting health server"
@@ -95,7 +95,7 @@ class HealthServer
Postal.logger.info "stopped health server", component: "health-server" Postal.logger.info "stopped health server", component: "health-server"
when /\AWEBrick [\d.]+/, when /\AWEBrick [\d.]+/,
/\Aruby ([\d.]+)/, /\Aruby ([\d.]+)/,
/\ARack::Handler::WEBrick is mounted/, /\ARackup::Handler::WEBrick is mounted/,
/\Aclose TCPSocket/, /\Aclose TCPSocket/,
/\Agoing to shutdown/ /\Agoing to shutdown/
# Don't actually print routine messages to avoid too much # Don't actually print routine messages to avoid too much
+1 -1
View File
@@ -38,7 +38,7 @@
= f.select :hold, [["Process all messages", false], ["Hold messages from this credential", true]], {}, :class => 'input input--select' = f.select :hold, [["Process all messages", false], ["Hold messages from this credential", true]], {}, :class => 'input input--select'
%p.fieldSet__text %p.fieldSet__text
You may wish to automatically hold all messages that are sent by this credential. This allows you to preview them 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 .fieldSetSubmit.buttonSet
= f.submit @credential.new_record? ? "Create credential" : "Save credential", :class => 'button button--positive js-form-submit' = f.submit @credential.new_record? ? "Create credential" : "Save credential", :class => 'button button--positive js-form-submit'
+3 -1
View File
@@ -55,6 +55,8 @@
%footer.siteContent__footer %footer.siteContent__footer
%ul.footer__links %ul.footer__links
%li.footer__name %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 "Documentation", "https://docs.postalserver.io", target: '_blank'
%li= link_to "Ask for help", "https://discussions.postalserver.io", target: '_blank' %li= link_to "Ask for help", "https://discussions.postalserver.io", target: '_blank'
+45 -19
View File
@@ -1,24 +1,50 @@
%ul.messageList %ul.messageList
- for message in messages - for message in messages
- if message.is_a?(QueuedMessage) - if message.is_a?(QueuedMessage)
- queued_message = message
- message = 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} - if message.nil? && queued_message
%p.messageList__subject= message.subject || "No subject" %li.messageList__message
%dl.messageList__addresses .messageList__link
%dt To .messageList__details
%dd %p.messageList__subject Deleted message ##{queued_message.message_id}
- if message.rcpt_to_return_path? %dl.messageList__addresses
%span.returnPathTag Return Path %dt Domain
- else %dd= queued_message.domain
= message.rcpt_to || "none" %dt Locked
%dt From %dd= queued_message.locked? ? "Yes" : "No"
%dd= message.mail_from || "none" .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) - else
%p.messageList__status %li.messageList__message
- if message.read? = link_to organization_server_message_path(organization, @server, message.id), :class => 'messageList__link' do
%span.label.label--purple Opened .messageList__details{:class => 'messageList__details--' + message.scope}
%span.label{:class => "label--messageStatus-#{message.status.underscore}"}= message.status.underscore.humanize %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
+1 -1
View File
@@ -14,4 +14,4 @@
This means that we no longer store the raw data for this e-mail This means that we no longer store the raw data for this e-mail
or the e-mail didn't include a HTML part. or the e-mail didn't include a HTML part.
- else - 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)}
+2 -3
View File
@@ -13,12 +13,11 @@
all its mail servers & data will be deleted from our systems. all its mail servers & data will be deleted from our systems.
.dangerZone .dangerZone
%p.pageContent__text.u-margin %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> continue. <b class='u-red'>There will be no other confirmations.</b>
= form_tag [organization, :delete], :method => :delete, :remote => true do = form_tag [organization, :delete], :method => :delete, :remote => true do
= hidden_field_tag 'return_to', params[:return_to] = hidden_field_tag 'return_to', params[:return_to]
%p.u-margin %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 .buttonSet.u-center
= submit_tag "Delete this organization, mail servers and all messages", :class => 'button button--danger js-form-submit' = submit_tag "Delete this organization, mail servers and all messages", :class => 'button button--danger js-form-submit'
+2 -3
View File
@@ -10,12 +10,11 @@
immediately. immediately.
.dangerZone .dangerZone
%p.pageContent__text.u-margin %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> continue. <b class='u-red'>There will be no other confirmations.</b>
= form_tag [organization, @server], :remote => true, :method => :delete do = form_tag [organization, @server], :remote => true, :method => :delete do
= hidden_field_tag 'return_to', params[:return_to] = hidden_field_tag 'return_to', params[:return_to]
%p.u-margin %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 .buttonSet.u-center
= submit_tag "Delete this mail server and all messages", :class => 'button button--danger' = submit_tag "Delete this mail server and all messages", :class => 'button button--danger'
+14 -8
View File
@@ -6,13 +6,19 @@
.subPageBox__content .subPageBox__content
= form_tag login_path, :class => 'loginForm' do = form_tag login_path, :class => 'loginForm' do
= hidden_field_tag 'return_to', params[:return_to] = 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 - if Postal::Config.oidc.enabled?
%p.loginForm__input= password_field_tag 'password', '', :class => 'input input--text input--onWhite', :placeholder => "Your password", :tabindex => 2 .loginForm__oidcButton
.loginForm__submit = link_to "Login with #{Postal::Config.oidc.name}", "/auth/oidc", method: :post, class: 'button button--full'
%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? && 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
+19 -17
View File
@@ -6,15 +6,16 @@
= form_for @user, :url => settings_path, :remote => true do |f| = form_for @user, :url => settings_path, :remote => true do |f|
= f.error_messages = f.error_messages
%fieldset.fieldSet %fieldset.fieldSet
.fieldSet__field - if @user.password? && Postal::Config.oidc.local_authentication_enabled?
= label_tag :password, 'Your Password', :class => 'fieldSet__label' .fieldSet__field
.fieldSet__input = label_tag :password, 'Your Password', :class => 'fieldSet__label'
= 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" .fieldSet__input
- if @password_correct = 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"
= hidden_field_tag :password, params[:password] - if @password_correct
%p.fieldSet__text = hidden_field_tag :password, params[:password]
In order to protect your account, you need to enter your current password in the field above %p.fieldSet__text
to authenticate the change of your details. 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 .fieldSet__title
Your details Your details
@@ -41,14 +42,15 @@
Choose the time zone that you'd like times to be displayed to you when you use our 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. web interface. By default, times are displayed in UTC.
.fieldSet__title - if @user.password? && Postal::Config.oidc.local_authentication_enabled?
Change your password? .fieldSet__title
.fieldSet__field Change your password?
= f.label :password, "New Password", :class => 'fieldSet__label' .fieldSet__field
.fieldSet__input = f.label :password, "New Password", :class => 'fieldSet__label'
.inputPair .fieldSet__input
= f.password_field :password, :class => 'input input--text', :placeholder => "•••••••••••", :value => @user.password .inputPair
= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => "and confirm it", :value => @user.password_confirmation = 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 %p.fieldSetSubmit.buttonSet
+17 -3
View File
@@ -11,11 +11,25 @@
.fieldSet__input= f.text_field :last_name, :class => 'input input--text' .fieldSet__input= f.text_field :last_name, :class => 'input input--text'
.fieldSet__field .fieldSet__field
= f.label :email_address, :class => 'fieldSet__label' = f.label :email_address, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :email_address, :class => 'input input--text', autocomplete: 'one-time-code' .fieldSet__input
- unless @user.persisted? = 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 .fieldSet__field
= f.label :password, :class => 'fieldSet__label' = 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 .fieldSet__field
= f.label :password_confirmation, "Confirm".html_safe, :class => 'fieldSet__label' = 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' .fieldSet__input= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'
+8 -3
View File
@@ -7,15 +7,20 @@
%ul.userList.u-margin %ul.userList.u-margin
- for user in @users - for user in @users
%li.userList__item %li.userList__item
= image_tag user.avatar_url, :class => 'userList__avatar'
.userList__details .userList__details
%p.userList__name %p.userList__name
= user.name = user.name
- if user.admin? - 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 %p.userList__email= user.email_address
%ul.userList__actions %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' %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' %p.u-center= link_to "Add a new user", :new_user, :class => 'button button--positive'
+3 -1
View File
@@ -12,7 +12,9 @@ require "sprockets/railtie"
# Require the gems listed in Gemfile, including any gems # Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production. # 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 module Postal
class Application < Rails::Application class Application < Rails::Application
+1 -1
View File
@@ -6,7 +6,7 @@ Rails.application.configure do
# In the development environment your application's code is reloaded on # In the development environment your application's code is reloaded on
# every request. This slows down response time but is perfect for development # 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. # 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. # Do not eager load code on boot.
config.eager_load = false config.eager_load = false
+1 -1
View File
@@ -4,7 +4,7 @@ Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb. # Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests. # 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 # Eager load code on boot. This eager loads most of Rails and
# your application in memory, allowing both threaded web servers # your application in memory, allowing both threaded web servers
+1 -1
View File
@@ -7,7 +7,7 @@ Rails.application.configure do
# test suite. You never need to work with it otherwise. Remember that # 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 # 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! # 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 # 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 # just for the purpose of running a single test. If you are using a tool that
@@ -6,5 +6,5 @@
# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported
# notations and behaviors. # notations and behaviors.
Rails.application.config.filter_parameters += [ Rails.application.config.filter_parameters += [
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn,
] ]
+1
View File
@@ -16,6 +16,7 @@
ActiveSupport::Inflector.inflections(:en) do |inflect| ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "DKIM" inflect.acronym "DKIM"
inflect.acronym "HTTP" inflect.acronym "HTTP"
inflect.acronym "OIDC"
inflect.acronym "SMTP" inflect.acronym "SMTP"
inflect.acronym "UUID" inflect.acronym "UUID"
+28
View File
@@ -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
+2 -2
View File
@@ -1,8 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
if Postal::Config.rails.secret_key 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 else
warn "No secret key was specified in the Postal config file. Using one for just this session" 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 end
+12
View File
@@ -1,6 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
Rails.application.routes.draw do 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 scope "org/:org_permalink", as: "organization" do
resources :domains, only: [:index, :new, :create, :destroy] do resources :domains, only: [:index, :new, :create, :destroy] do
match :verify, on: :member, via: [:get, :post] 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" => "sessions#begin_password_reset", :via => [:get, :post]
match "login/reset/:token" => "sessions#finish_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" get "ip" => "sessions#ip"
root "organizations#index" 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
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table "additional_route_endpoints", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "route_id" t.integer "route_id"
t.string "endpoint_type" 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.string "password_reset_token"
t.datetime "password_reset_token_valid_until", precision: nil t.datetime "password_reset_token_valid_until", precision: nil
t.boolean "admin", default: false 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 ["email_address"], name: "index_users_on_email_address", length: 8
t.index ["uuid"], name: "index_users_on_uuid", length: 8 t.index ["uuid"], name: "index_users_on_uuid", length: 8
end end
+27 -7
View File
@@ -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_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_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_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_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_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_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_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_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 | | `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_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_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_HOST` | String | Hostname for the main MariaDB server | localhost |
| `MAIN_DB_PORT` | Integer | The MariaDB port to connect to | 3306 | | `MAIN_DB_PORT` | Integer | The MariaDB port to connect to | 3306 |
| `MAIN_DB_USERNAME` | String | The MariaDB username | postal | | `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_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_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_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 | | `LOGGING_HIGHLIGHTING_ENABLED` | Boolean | Enable highlighting of log lines | false |
| `GELF_HOST` | String | GELF-capable host to send logs to | | | `GELF_HOST` | String | GELF-capable host to send logs to | |
| `GELF_PORT` | Integer | GELF port to send logs to | 12201 | | `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 | | `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_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_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_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_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_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/postal/smtp.key | | `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_TLS_CIPHERS` | String | Override ciphers to use for SSL | |
| `SMTP_SERVER_SSL_VERSION` | String | The SSL versions which are supported | SSLv23 | | `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_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_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 | | | `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_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_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 | | `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
View File
@@ -24,11 +24,17 @@ postal:
# Append a Resend-Sender header to all outgoing e-mails # Append a Resend-Sender header to all outgoing e-mails
use_resent_sender_header: true use_resent_sender_header: true
# Path to the private key used for signing # 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 # An array of SMTP relays in the format of smtp://host:port
smtp_relays: [] smtp_relays: []
# An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses) # An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses)
trusted_proxies: [] 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: web_server:
# The default port the web server should listen on unless overriden by the PORT environment variable # 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 default_health_server_port: 9090
# The default bind address for the worker health server to listen on # The default bind address for the worker health server to listen on
default_health_server_bind_address: 127.0.0.1 default_health_server_bind_address: 127.0.0.1
# The number of threads to execute within each worker
threads: 2
main_db: main_db:
# Hostname for the main MariaDB server # Hostname for the main MariaDB server
@@ -81,6 +89,8 @@ logging:
sentry_dsn: sentry_dsn:
# Enable the Postal logger to log to STDOUT # Enable the Postal logger to log to STDOUT
enabled: true enabled: true
# The minimum log level for the Postal logger (debug, info, warn, error, fatal)
level: INFO
# Enable highlighting of log lines # Enable highlighting of log lines
highlighting_enabled: false 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 # The default port the SMTP server should listen on unless overriden by the PORT environment variable
default_port: 25 default_port: 25
# The default bind address the SMTP server should listen on unless overriden by the BIND_ADDRESS environment variable # 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 # The default port for the SMTP server health server to listen on
default_health_server_port: 9091 default_health_server_port: 9091
# The default bind address for the SMTP server health server to listen on # 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) # Enable TLS for the SMTP server (requires certificate)
tls_enabled: false tls_enabled: false
# The path to the SMTP server's TLS certificate # 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 # 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 # Override ciphers to use for SSL
tls_ciphers: tls_ciphers:
# The SSL versions which are supported # The SSL versions which are supported
ssl_version: SSLv23 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 proxy_protocol: false
# Enable connection logging # Enable connection logging
log_connections: false log_connections: false
@@ -217,3 +227,37 @@ migration_waiter:
attempts: 120 attempts: 120
# The number of seconds to wait between each migration check # The number of seconds to wait between each migration check
sleep_time: 2 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
View File
@@ -1,4 +1,3 @@
version: "3"
services: services:
postal: postal:
image: ${POSTAL_IMAGE} image: ${POSTAL_IMAGE}
+74 -14
View File
@@ -11,16 +11,19 @@ require "konfig/sources/environment"
require "konfig/sources/yaml" require "konfig/sources/yaml"
require "dotenv" require "dotenv"
require "klogger" require "klogger"
require "securerandom"
require_relative "error" require_relative "error"
require_relative "version"
require_relative "config_schema" require_relative "config_schema"
require_relative "legacy_config_source" require_relative "legacy_config_source"
require_relative "signer"
module Postal module Postal
class << self class << self
attr_writer :current_process_type
# Return the path to the config file # Return the path to the config file
# #
# @return [String] # @return [String]
@@ -37,30 +40,36 @@ module Postal
Dotenv.load(".env") Dotenv.load(".env")
sources << Konfig::Sources::Environment.new(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 # 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' # either be legacy (v1) or new (v2). Any file without a 'version'
# key is a legacy file whereas new-style config files will include # key is a legacy file whereas new-style config files will include
# the 'version: 2' key/value. # the 'version: 2' key/value.
if File.file?(config_file_path) 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) config_file = File.read(config_file_path)
yaml = YAML.safe_load(config_file) yaml = YAML.safe_load(config_file)
config_version = yaml["version"] || 1 config_version = yaml["version"] || 1
case config_version case config_version
when 1 when 1
puts "WARNING: Using legacy config file format. Upgrade your postal.yml to use" unless silence_config_messages
puts "version 2 of the Postal configuration or configure using environment" warn "WARNING: Using legacy config file format. Upgrade your postal.yml to use"
puts "variables. See https://postalserver.io/config-v2 for details." 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) sources << LegacyConfigSource.new(yaml)
when 2 when 2
sources << Konfig::Sources::YAML.new(config_file) sources << Konfig::Sources::YAML.new(config_file)
else else
raise "Invalid version specified in Postal config file. Must be 1 or 2." raise "Invalid version specified in Postal config file. Must be 1 or 2."
end end
else elsif !silence_config_messages
puts "No configuration file found at #{config_file_path}" warn "No configuration file found at #{config_file_path}"
puts "Only using environment variables for configuration" warn "Only using environment variables for configuration"
end end
# Build configuration with the provided sources. # Build configuration with the provided sources.
@@ -74,16 +83,21 @@ module Postal
def logger def logger
@logger ||= begin @logger ||= begin
k = Klogger.new(nil, destination: Config.logging.enabled? ? $stdout : "/dev/null", highlight: Config.logging.highlighting_enabled?) 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.add_destination(graylog_logging_destination) if Config.gelf.host.present?
k k
end end
end end
def process_identity
@process_identity ||= SecureRandom.hex(8)
end
def process_name def process_name
@process_name ||= begin @process_name ||= begin
"host:#{Socket.gethostname} pid:#{Process.pid}" "host:#{Socket.gethostname} pid:#{Process.pid} id:#{process_identity}"
rescue StandardError rescue StandardError
"pid:#{Process.pid}" "pid:#{Process.pid} id:#{process_identity}"
end end
end end
@@ -98,12 +112,15 @@ module Postal
"#{locker_name} #{suffix}" "#{locker_name} #{suffix}"
end end
def signing_key def signer
@signing_key ||= OpenSSL::PKey::RSA.new(File.read(Config.postal.signing_key_path)) @signer ||= begin
key = OpenSSL::PKey::RSA.new(File.read(Config.postal.signing_key_path))
Signer.new(key)
end
end end
def rp_dkim_dns_record 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};" "v=DKIM1; t=s; h=sha256; p=#{public_key};"
end end
@@ -119,13 +136,56 @@ module Postal
notifier.notify!(short_message: short_message, **{ notifier.notify!(short_message: short_message, **{
facility: Config.gelf.facility, facility: Config.gelf.facility,
_environment: Config.rails.environment, _environment: Config.rails.environment,
_version: Postal::VERSION.to_s, _version: Postal.version.to_s,
_group_ids: group_ids.join(" ") _group_ids: group_ids.join(" ")
}.merge(payload.transform_keys { |k| "_#{k}".to_sym }.transform_values(&:to_s))) }.merge(payload.transform_keys { |k| "_#{k}".to_sym }.transform_values(&:to_s)))
end end
end 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 end
Config = initialize_config Config = initialize_config
+108 -1
View File
@@ -91,6 +91,24 @@ module Postal
description "An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses)" description "An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses)"
transform { |ip| IPAddr.new(ip) } transform { |ip| IPAddr.new(ip) }
end 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 end
group :web_server do group :web_server do
@@ -120,6 +138,11 @@ module Postal
description "The default bind address for the worker health server to listen on" description "The default bind address for the worker health server to listen on"
default "127.0.0.1" default "127.0.0.1"
end end
integer :threads do
description "The number of threads to execute within each worker"
default 2
end
end end
group :main_db do group :main_db do
@@ -204,6 +227,19 @@ module Postal
default true default true
end 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 boolean :highlighting_enabled do
description "Enable highlighting of log lines" description "Enable highlighting of log lines"
default false default false
@@ -274,7 +310,7 @@ module Postal
end end
boolean :proxy_protocol do 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 default false
end end
@@ -503,6 +539,77 @@ module Postal
default 2 default 2
end end
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 end
class << self class << self
+27 -13
View File
@@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require "net/https" require "net/https"
require "resolv"
require "uri" require "uri"
module Postal module Postal
@@ -40,25 +41,31 @@ module Postal
end end
if options[:sign] if options[:sign]
signature = EncryptoSigno.sign(Postal.signing_key, request.body.to_s).gsub("\n", "") request.add_field "X-Postal-Signature-KID", Postal.signer.jwk.kid
request.add_field "X-Postal-Signature", signature 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 end
request["User-Agent"] = options[:user_agent] || "Postal/#{Postal.version}" request["User-Agent"] = options[:user_agent] || "Postal/#{Postal.version}"
connection = Net::HTTP.new(uri.host, uri.port) timeout = options[:timeout] || 60
ssl = uri.scheme == "https"
if uri.scheme == "https"
connection.use_ssl = true
connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
ssl = true
else
ssl = false
end
begin begin
timeout = options[:timeout] || 60
Timeout.timeout(timeout) do 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) result = connection.request(request)
{ {
code: result.code.to_i, code: result.code.to_i,
@@ -67,6 +74,13 @@ module Postal
secure: ssl secure: ssl
} }
end end
rescue BlockedDestinationError => e
{
code: -4,
body: e.message,
headers: {},
secure: ssl
}
rescue OpenSSL::SSL::SSLError rescue OpenSSL::SSL::SSLError
{ {
code: -3, code: -3,
@@ -74,7 +88,7 @@ module Postal
headers: {}, headers: {},
secure: ssl 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, code: -2,
body: e.message, body: e.message,
+202
View File
@@ -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
+26 -17
View File
@@ -70,7 +70,7 @@ module Postal
# Return the total size of all stored messages # Return the total size of all stored messages
# #
def total_size 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 end
# #
@@ -151,11 +151,11 @@ module Postal
if options[:count] if options[:count]
sql_query << " COUNT(id) AS count" sql_query << " COUNT(id) AS count"
elsif options[:fields] elsif options[:fields]
sql_query << (" " + options[:fields].map { |f| "`#{f}`" }.join(", ")) sql_query << (" " + options[:fields].map { |f| escape_identifier(f) }.join(", "))
else else
sql_query << " *" sql_query << " *"
end end
sql_query << " FROM `#{database_name}`.`#{table}`" sql_query << " FROM #{escape_identifier(database_name)}.#{escape_identifier(table)}"
if options[:where].present? if options[:where].present?
sql_query << (" " + build_where_string(options[:where], " AND ")) sql_query << (" " + build_where_string(options[:where], " AND "))
end end
@@ -163,7 +163,7 @@ module Postal
direction = (options[:direction] || "ASC").upcase direction = (options[:direction] || "ASC").upcase
raise Postal::Error, "Invalid direction #{options[:direction]}" unless %w[ASC DESC].include?(direction) 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 end
if options[:limit] if options[:limit]
@@ -211,7 +211,7 @@ module Postal
# Will return the total number of affected rows. # Will return the total number of affected rows.
# #
def update(table, attributes, options = {}) 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)}" sql_query << " #{hash_to_sql(attributes)}"
if options[:where] if options[:where]
sql_query << (" " + build_where_string(options[:where])) sql_query << (" " + build_where_string(options[:where]))
@@ -227,8 +227,8 @@ module Postal
# Will return the ID of the new item. # Will return the ID of the new item.
# #
def insert(table, attributes) def insert(table, attributes)
sql_query = "INSERT INTO `#{database_name}`.`#{table}`" sql_query = "INSERT INTO #{escape_identifier(database_name)}.#{escape_identifier(table)}"
sql_query << (" (" + attributes.keys.map { |k| "`#{k}`" }.join(", ") + ")") sql_query << (" (" + attributes.keys.map { |k| escape_identifier(k) }.join(", ") + ")")
sql_query << (" VALUES (" + attributes.values.map { |v| escape(v) }.join(", ") + ")") sql_query << (" VALUES (" + attributes.values.map { |v| escape(v) }.join(", ") + ")")
with_mysql do |mysql| with_mysql do |mysql|
query_on_connection(mysql, sql_query) query_on_connection(mysql, sql_query)
@@ -243,8 +243,8 @@ module Postal
if values.empty? if values.empty?
nil nil
else else
sql_query = "INSERT INTO `#{database_name}`.`#{table}`" sql_query = "INSERT INTO #{escape_identifier(database_name)}.#{escape_identifier(table)}"
sql_query << (" (" + keys.map { |k| "`#{k}`" }.join(", ") + ")") sql_query << (" (" + keys.map { |k| escape_identifier(k) }.join(", ") + ")")
sql_query << " VALUES " sql_query << " VALUES "
sql_query << values.map { |v| "(" + v.map { |r| escape(r) }.join(", ") + ")" }.join(", ") sql_query << values.map { |v| "(" + v.map { |r| escape(r) }.join(", ") + ")" }.join(", ")
query(sql_query) query(sql_query)
@@ -260,7 +260,7 @@ module Postal
# Will return the total number of affected rows. # Will return the total number of affected rows.
# #
def delete(table, options = {}) 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 ")) sql_query << (" " + build_where_string(options[:where], " AND "))
with_mysql do |mysql| with_mysql do |mysql|
query_on_connection(mysql, sql_query) query_on_connection(mysql, sql_query)
@@ -351,32 +351,41 @@ module Postal
def hash_to_sql(hash, joiner = ", ") def hash_to_sql(hash, joiner = ", ")
hash.map do |key, value| hash.map do |key, value|
column = escape_identifier(key)
if value.is_a?(Array) && value.all? { |v| v.is_a?(Integer) } 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) elsif value.is_a?(Array)
escaped_values = value.map { |v| escape(v) }.join(", ") escaped_values = value.map { |v| escape(v) }.join(", ")
"`#{key}` IN (#{escaped_values})" "#{column} IN (#{escaped_values})"
elsif value.is_a?(Hash) elsif value.is_a?(Hash)
sql = [] sql = []
value.each do |operator, inner_value| value.each do |operator, inner_value|
case operator case operator
when :less_than when :less_than
sql << "`#{key}` < #{escape(inner_value)}" sql << "#{column} < #{escape(inner_value)}"
when :greater_than when :greater_than
sql << "`#{key}` > #{escape(inner_value)}" sql << "#{column} > #{escape(inner_value)}"
when :less_than_or_equal_to when :less_than_or_equal_to
sql << "`#{key}` <= #{escape(inner_value)}" sql << "#{column} <= #{escape(inner_value)}"
when :greater_than_or_equal_to when :greater_than_or_equal_to
sql << "`#{key}` >= #{escape(inner_value)}" sql << "#{column} >= #{escape(inner_value)}"
end end
end end
sql.empty? ? "1=1" : sql.join(joiner) sql.empty? ? "1=1" : sql.join(joiner)
else else
"`#{key}` = #{escape(value)}" "#{column} = #{escape(value)}"
end end
end.join(joiner) end.join(joiner)
end 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 end
end end
+67
View File
@@ -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
-18
View File
@@ -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
+1 -1
View File
@@ -19,7 +19,7 @@ module Postal
contents << " #{name}: []" contents << " #{name}: []"
else else
contents << " #{name}:" contents << " #{name}:"
attr.default.each do |d| attr.transform(attr.default).each do |d|
contents << " - #{d}" contents << " - #{d}"
end end
end end
+1 -15
View File
@@ -48,25 +48,11 @@ class TrackingMiddleware
Sentry.capture_exception(e) if defined?(Sentry) Sentry.capture_exception(e) if defined?(Sentry)
end end
source_image = request.params["src"] if request.params["src"].nil?
case source_image
when nil
headers = {} headers = {}
headers["Content-Type"] = "image/png" headers["Content-Type"] = "image/png"
headers["Content-Length"] = TRACKING_PIXEL.bytesize.to_s headers["Content-Length"] = TRACKING_PIXEL.bytesize.to_s
[200, headers, [TRACKING_PIXEL]] [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 else
[400, {}, ["Invalid/missing source image"]] [400, {}, ["Invalid/missing source image"]]
end end
+1
View File
@@ -1,4 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
ENV["SILENCE_POSTAL_CONFIG_LOCATION_MESSAGE"] = "true"
require File.expand_path("../lib/postal/config", __dir__) require File.expand_path("../lib/postal/config", __dir__)
puts Postal.rp_dkim_dns_record puts Postal.rp_dkim_dns_record
-21
View File
@@ -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
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
# frozen_string_literal: true # frozen_string_literal: true
require File.expand_path("../lib/postal/version", __dir__) require File.expand_path("../lib/postal/config", __dir__)
puts Postal.version 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
+237
View File
@@ -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
+168
View File
@@ -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
+6
View File
@@ -19,5 +19,11 @@ FactoryBot.define do
factory :ip_pool do factory :ip_pool do
name { "Default Pool" } name { "Default Pool" }
default { true } 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
end end
+10 -8
View File
@@ -5,19 +5,21 @@
# Table name: users # Table name: users
# #
# id :integer not null, primary key # id :integer not null, primary key
# uuid :string(255) # admin :boolean default(FALSE)
# first_name :string(255)
# last_name :string(255)
# email_address :string(255) # email_address :string(255)
# password_digest :string(255)
# time_zone :string(255)
# email_verification_token :string(255) # email_verification_token :string(255)
# email_verified_at :datetime # email_verified_at :datetime
# created_at :datetime # first_name :string(255)
# updated_at :datetime # last_name :string(255)
# oidc_issuer :string(255)
# oidc_uid :string(255)
# password_digest :string(255)
# password_reset_token :string(255) # password_reset_token :string(255)
# password_reset_token_valid_until :datetime # password_reset_token_valid_until :datetime
# admin :boolean default(FALSE) # time_zone :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
# #
# Indexes # Indexes
# #
+37
View File
@@ -0,0 +1,37 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe ApplicationHelper, type: :helper do
describe "#endpoint_options_for_select" do
let(:server) { create(:server) }
context "when an endpoint has HTML characters in its description" do
let(:payload) { %q(x'"><script>alert(1)</script>) }
before do
create(:http_endpoint, server: server, name: payload)
end
it "HTML-escapes the endpoint description in the option text" do
html = helper.endpoint_options_for_select(server)
# The raw payload must not appear verbatim — if it does, the browser
# will execute the <script> tag.
expect(html).not_to include("<script>alert(1)</script>")
# Escaped form should appear instead.
expect(html).to include("&lt;script&gt;alert(1)&lt;/script&gt;")
end
it "does not allow the payload to break out of the option tag" do
html = helper.endpoint_options_for_select(server)
# The ' and > characters in the payload must be escaped so they
# cannot close the opening <option value='...'> or terminate the
# element early.
expect(html).not_to match(/<option[^>]*>[^<]*<script/)
end
end
end
end
+6 -6
View File
@@ -66,7 +66,7 @@ RSpec.describe DNSResolver do
it "returns a list of TXT records" do it "returns a list of TXT records" do
expect(resolver.txt("dnstest.postalserver.io").sort).to eq [ expect(resolver.txt("dnstest.postalserver.io").sort).to eq [
"an example txt record", "an example txt record",
"another example" "another example",
] ]
end end
@@ -79,7 +79,7 @@ RSpec.describe DNSResolver do
it "returns a list of TXT records" do it "returns a list of TXT records" do
expect(resolver.txt("dnstest.postalserver.io", raise_timeout_errors: true).sort).to eq [ expect(resolver.txt("dnstest.postalserver.io", raise_timeout_errors: true).sort).to eq [
"an example txt record", "an example txt record",
"another example" "another example",
] ]
end end
@@ -120,7 +120,7 @@ RSpec.describe DNSResolver do
it "returns a list of MX records" do it "returns a list of MX records" do
expect(resolver.mx("dnstest.postalserver.io")).to eq [ expect(resolver.mx("dnstest.postalserver.io")).to eq [
[10, "mx1.dnstest.postalserver.io"], [10, "mx1.dnstest.postalserver.io"],
[20, "mx2.dnstest.postalserver.io"] [20, "mx2.dnstest.postalserver.io"],
] ]
end end
@@ -133,7 +133,7 @@ RSpec.describe DNSResolver do
it "returns a list of MX records" do it "returns a list of MX records" do
expect(resolver.mx("dnstest.postalserver.io", raise_timeout_errors: true)).to eq [ expect(resolver.mx("dnstest.postalserver.io", raise_timeout_errors: true)).to eq [
[10, "mx1.dnstest.postalserver.io"], [10, "mx1.dnstest.postalserver.io"],
[20, "mx2.dnstest.postalserver.io"] [20, "mx2.dnstest.postalserver.io"],
] ]
end end
@@ -150,7 +150,7 @@ RSpec.describe DNSResolver do
it "returns the nameserver names that are authoritative for the given domain" do it "returns the nameserver names that are authoritative for the given domain" do
expect(resolver.effective_ns("postalserver.io").sort).to eq [ expect(resolver.effective_ns("postalserver.io").sort).to eq [
"prestigious-honeybadger.katapultdns.com", "prestigious-honeybadger.katapultdns.com",
"the-cake-is-a-lie.katapultdns.com" "the-cake-is-a-lie.katapultdns.com",
] ]
end end
@@ -163,7 +163,7 @@ RSpec.describe DNSResolver do
it "returns a list of NS records" do it "returns a list of NS records" do
expect(resolver.effective_ns("postalserver.io", raise_timeout_errors: true).sort).to eq [ expect(resolver.effective_ns("postalserver.io", raise_timeout_errors: true).sort).to eq [
"prestigious-honeybadger.katapultdns.com", "prestigious-honeybadger.katapultdns.com",
"the-cake-is-a-lie.katapultdns.com" "the-cake-is-a-lie.katapultdns.com",
] ]
end end
@@ -62,13 +62,28 @@ module MessageDequeuer
@queued_message3 = create(:queued_message, message: @message3) @queued_message3 = create(:queued_message, message: @message3)
end end
it "calls the single message process for the initial message and all batchable messages" do context "when postal.batch_queued_messages is enabled" do
[queued_message, @queued_message2, @queued_message3].each do |msg| it "calls the single message process for the initial message and all batchable messages" do
expect(SingleMessageProcessor).to receive(:process).with(msg, [queued_message, @queued_message2, @queued_message3].each do |msg|
logger: logger, expect(SingleMessageProcessor).to receive(:process).with(msg,
state: processor.state) logger: logger,
state: processor.state)
end
processor.process
end
end
context "when postal.batch_queued_messages is disabled" do
before do
allow(Postal::Config.postal).to receive(:batch_queued_messages?) { false }
end
it "does not call the single message process more than once" do
expect(SingleMessageProcessor).to receive(:process).once.with(queued_message,
logger: logger,
state: processor.state)
processor.process
end end
processor.process
end end
end end
+192
View File
@@ -0,0 +1,192 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Postal::HTTP::AddressGuard do
describe ".safe_connect_address" do
subject(:call) { described_class.safe_connect_address(host) }
before do
allow(Postal::Config.postal).to receive(:allowed_request_destinations).and_return(allowlist)
end
let(:allowlist) { [] }
context "when given a public IP literal" do
let(:host) { "93.184.216.34" }
it "returns the address to connect to" do
expect(call).to eq "93.184.216.34"
end
end
context "when given a public IPv6 literal" do
let(:host) { "2606:2800:220:1:248:1893:25c8:1946" }
before { allow(described_class).to receive(:ipv6_supported?).and_return(true) }
it "returns the address to connect to" do
expect(call).to eq "2606:2800:220:1:248:1893:25c8:1946"
end
end
[
"127.0.0.1",
"10.0.0.1",
"172.16.5.4",
"192.168.1.1",
"169.254.169.254", # cloud metadata
"100.64.0.1", # carrier-grade NAT
"0.0.0.0",
"::1",
"fd00::1", # unique-local IPv6
"fe80::1", # link-local IPv6
"::ffff:127.0.0.1", # IPv4-mapped loopback
].each do |blocked|
context "when given the blocked address #{blocked}" do
let(:host) { blocked }
it "raises BlockedDestinationError" do
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError)
end
end
end
context "when given a hostname that resolves to a public address" do
let(:host) { "example.com" }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return(["93.184.216.34"])
end
it "returns the resolved address" do
expect(call).to eq "93.184.216.34"
end
end
context "when given a hostname that resolves to a private address" do
let(:host) { "internal.example.com" }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return(["10.1.2.3"])
end
it "raises BlockedDestinationError" do
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError)
end
end
context "when a hostname resolves to both a public and a private address" do
let(:host) { "rebind.example.com" }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return(["93.184.216.34", "127.0.0.1"])
end
it "raises BlockedDestinationError because one address is blocked" do
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError)
end
end
context "when a hostname resolves to both IPv4 and IPv6 addresses" do
let(:host) { "dualstack.example.com" }
let(:ipv6) { "2606:2800:220:1:248:1893:25c8:1946" }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return([ipv6, "93.184.216.34"])
end
context "and the server does not support IPv6" do
before { allow(described_class).to receive(:ipv6_supported?).and_return(false) }
it "connects over IPv4" do
expect(call).to eq "93.184.216.34"
end
end
context "and the server supports IPv6" do
before { allow(described_class).to receive(:ipv6_supported?).and_return(true) }
it "still prefers IPv4 for predictability" do
expect(call).to eq "93.184.216.34"
end
end
end
context "when a hostname resolves only to an IPv6 address" do
let(:host) { "v6only.example.com" }
let(:ipv6) { "2606:2800:220:1:248:1893:25c8:1946" }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return([ipv6])
end
context "and the server does not support IPv6" do
before { allow(described_class).to receive(:ipv6_supported?).and_return(false) }
it "raises a SocketError because the address is unreachable" do
expect { call }.to raise_error(SocketError)
end
end
context "and the server supports IPv6" do
before { allow(described_class).to receive(:ipv6_supported?).and_return(true) }
it "connects over IPv6" do
expect(call).to eq ipv6
end
end
end
context "when a hostname cannot be resolved" do
let(:host) { "nope.example.com" }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return([])
end
it "raises BlockedDestinationError" do
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError, /resolve/)
end
end
context "when the host is blank" do
let(:host) { "" }
it "raises BlockedDestinationError" do
expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError)
end
end
context "when a blocked address is allowlisted by CIDR" do
let(:host) { "10.0.0.5" }
let(:allowlist) { ["10.0.0.0/8"] }
it "returns the address" do
expect(call).to eq "10.0.0.5"
end
end
context "when a blocked address is allowlisted by exact IP" do
let(:host) { "127.0.0.1" }
let(:allowlist) { ["127.0.0.1"] }
it "returns the address" do
expect(call).to eq "127.0.0.1"
end
end
context "when a hostname resolving to a private address is allowlisted by name" do
let(:host) { "internal.example.com" }
let(:allowlist) { ["internal.example.com"] }
before do
allow(Resolv).to receive(:getaddresses).with(host).and_return(["10.1.2.3"])
end
it "returns the resolved address" do
expect(call).to eq "10.1.2.3"
end
end
end
end
+80
View File
@@ -0,0 +1,80 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Postal::HTTP do
before do
allow(Postal::Config.postal).to receive(:allowed_request_destinations).and_return([])
end
describe ".post" do
context "when the host resolves to a blocked address" do
before do
allow(Resolv).to receive(:getaddresses).with("internal.example.com").and_return(["127.0.0.1"])
end
it "does not make a request and returns a blocked-destination result" do
result = described_class.post("http://internal.example.com/hook", json: "{}")
expect(result[:code]).to eq(-4)
expect(result[:body]).to match(/not permitted/)
expect(WebMock).not_to have_requested(:post, "http://internal.example.com/hook")
end
end
context "when resolving the host raises an error" do
before do
allow(Resolv).to receive(:getaddresses).with("example.com").and_raise(Resolv::ResolvError, "resolver failed")
end
it "returns a connection error result" do
result = described_class.post("http://example.com/hook", json: "{}")
expect(result[:code]).to eq(-2)
expect(result[:body]).to match(/resolver failed/)
expect(WebMock).not_to have_requested(:post, "http://example.com/hook")
end
end
context "when resolving the host exceeds the request timeout" do
before do
allow(Resolv).to receive(:getaddresses).with("example.com") do
sleep 0.2
["93.184.216.34"]
end
end
it "returns a timeout result before making a request" do
result = described_class.post("http://example.com/hook", json: "{}", timeout: 0.05)
expect(result[:code]).to eq(-1)
expect(WebMock).not_to have_requested(:post, "http://example.com/hook")
end
end
context "when the host resolves to a public address" do
before do
allow(Resolv).to receive(:getaddresses).with("example.com").and_return(["93.184.216.34"])
stub_request(:post, "http://example.com/hook").to_return(status: 200, body: "OK")
end
it "pins the connection to the validated address and performs the request" do
expect_any_instance_of(Net::HTTP).to receive(:ipaddr=).with("93.184.216.34").and_call_original
result = described_class.post("http://example.com/hook", json: "{}")
expect(result[:code]).to eq(200)
expect(WebMock).to have_requested(:post, "http://example.com/hook")
end
end
context "when the blocked host is allowlisted" do
before do
allow(Postal::Config.postal).to receive(:allowed_request_destinations).and_return(["internal.example.com"])
allow(Resolv).to receive(:getaddresses).with("internal.example.com").and_return(["10.0.0.5"])
stub_request(:post, "http://internal.example.com/hook").to_return(status: 200, body: "OK")
end
it "performs the request" do
result = described_class.post("http://internal.example.com/hook", json: "{}")
expect(result[:code]).to eq(200)
expect(WebMock).to have_requested(:post, "http://internal.example.com/hook")
end
end
end
end
+1 -1
View File
@@ -73,7 +73,7 @@ module Postal
it "returns a value for postal.smtp_relays" do it "returns a value for postal.smtp_relays" do
expect(config.postal.smtp_relays).to eq [ expect(config.postal.smtp_relays).to eq [
{ "host" => "1.2.3.4", "port" => 25, "ssl_mode" => "Auto" }, { "host" => "1.2.3.4", "port" => 25, "ssl_mode" => "Auto" },
{ "host" => "2.2.2.2", "port" => 2525, "ssl_mode" => "None" } { "host" => "2.2.2.2", "port" => 2525, "ssl_mode" => "None" },
] ]
end end
end end
@@ -14,5 +14,64 @@ describe Postal::MessageDB::Database do
it "should return the current schema version" do it "should return the current schema version" do
expect(database.schema_version).to be_a Integer expect(database.schema_version).to be_a Integer
end end
describe "#escape_identifier" do
it "wraps a plain identifier in backticks" do
expect(database.send(:escape_identifier, "id")).to eq "`id`"
end
it "doubles embedded backticks so the value cannot break out of the quoting" do
expect(database.send(:escape_identifier, "id`=0 OR SLEEP(5)#"))
.to eq "`id``=0 OR SLEEP(5)#`"
end
it "coerces non-string identifiers to a string" do
expect(database.send(:escape_identifier, :token)).to eq "`token`"
end
end
describe "#hash_to_sql" do
it "builds a simple equality condition" do
expect(database.send(:hash_to_sql, { "id" => 5 })).to eq "`id` = '5'"
end
it "builds an IN condition for an array of integers" do
expect(database.send(:hash_to_sql, { "id" => [1, 2] })).to eq "`id` IN (1, 2)"
end
it "builds operator conditions for a hash value" do
expect(database.send(:hash_to_sql, { "id" => { greater_than: 1 } }))
.to eq "`id` > '1'"
end
# Regression tests for GHSA-x2hq-rfpg-3xr5: a backtick in the condition
# key must be neutralised so it cannot close the identifier quoting and
# inject arbitrary SQL.
it "neutralises a backtick injection in an equality key" do
sql = database.send(:hash_to_sql, { "id`=0 OR SLEEP(5)#" => "x" })
expect(sql).to eq "`id``=0 OR SLEEP(5)#` = 'x'"
end
it "neutralises a backtick injection in an IN key" do
sql = database.send(:hash_to_sql, { "id`)#" => %w[a b] })
expect(sql).to eq "`id``)#` IN ('a', 'b')"
end
it "neutralises a backtick injection in an operator key" do
sql = database.send(:hash_to_sql, { "id`#" => { greater_than: 1 } })
expect(sql).to eq "`id``#` > '1'"
end
end
describe "#select with a hostile condition key" do
# End-to-end proof against the live test database: the injected key is
# treated as a single (non-existent) column identifier, so MySQL rejects
# the query instead of executing the injected SQL.
it "does not allow SQL injection via the condition key" do
expect do
database.select("messages", where: { "id`=0 OR 1=1#" => "x" }, limit: 1)
end.to raise_error(Mysql2::Error)
end
end
end end
end end

Some files were not shown because too many files have changed in this diff Show More