telex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 21555637ece74cdb455b24dddc9dcfd7193b8ec0a2cad21d0da1052a583d778c
4
+ data.tar.gz: e0f91a8b8acfa3f81db1b51bf13cc2bf8af522cc83106676b8c52563ae30b2f2
5
+ SHA512:
6
+ metadata.gz: 852c7dd0cbe3fc1011a99bbf0a98dc54c398390f66717c0a08143626e6c798aa41866d0b11e86618652f18ef26d7d59ade28a4b17df5591f4ad92fb37b396e1a
7
+ data.tar.gz: c563df784e2e03f8ea8521d3566d4f677967bac6ee762f7e9d33497f6825faedc5e9c19a22c56c987fb765110c338a2664e7f61f6defab22975d9c7b6f17766a
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright B.O.X
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # Telex
2
+
3
+ Email for our Rails apps through [Lettermint](https://lettermint.co), a Dutch provider with servers in the EU.
4
+
5
+ - **Sending:** a `:lettermint` delivery method for Action Mailer, over Lettermint's SMTP relay. Mailers, previews, `deliver_later` and gems that send mail (Devise…) work as usual.
6
+ - **Receiving:** a Lettermint ingress for Action Mailbox, next to the ones Rails ships for Postmark or Mailgun. Lettermint posts a signed webhook with a link to the original message; Telex checks the signature, downloads the message in a job and hands it to Action Mailbox, which routes it to the app's mailboxes.
7
+
8
+ Each app has its own Lettermint project: its own API token, logs, webhook and receiving subdomain (`reply.hq.box.paris`, `reply.ticket.box.paris`…).
9
+
10
+ ## Install in an app
11
+
12
+ ```ruby
13
+ # Gemfile
14
+ gem "telex", github: "boxprod/telex"
15
+ ```
16
+
17
+ ```yaml
18
+ # bin/rails credentials:edit
19
+ lettermint:
20
+ api_token: lm_... # the project's API token, also the SMTP password
21
+ webhook_secret: whsec_... # the inbound route's webhook secret, whsec_ included
22
+ ```
23
+
24
+ Or `LETTERMINT_API_TOKEN` and `LETTERMINT_WEBHOOK_SECRET` in the environment.
25
+
26
+ ### Sending
27
+
28
+ ```ruby
29
+ # config/environments/production.rb
30
+ config.action_mailer.delivery_method = :lettermint
31
+ ```
32
+
33
+ The sending domain must be verified in the Lettermint project (SPF, DKIM, return path). Lettermint's [SMTP headers](https://lettermint.co/docs/guides/send-email-with-smtp) (`X-Lettermint-Route`, `X-LM-Tag`, `X-LM-Metadata-*`…) can be set in a mailer with `headers[...]`.
34
+
35
+ ### Receiving
36
+
37
+ Install Action Mailbox if the app does not have it yet (it needs Active Storage):
38
+
39
+ ```sh
40
+ bin/rails action_mailbox:install db:migrate
41
+ ```
42
+
43
+ ```ruby
44
+ # config/environments/production.rb
45
+ config.action_mailbox.ingress = :lettermint
46
+ ```
47
+
48
+ In the Lettermint project, add an inbound route on the receiving subdomain (its MX records point to Lettermint) with the webhook
49
+
50
+ ```
51
+ https://<the app>/rails/action_mailbox/lettermint/inbound_emails
52
+ ```
53
+
54
+ Then route messages in `app/mailboxes/application_mailbox.rb` as with any Action Mailbox app. Inbound needs Lettermint's Starter plan or above.
55
+
56
+ What Telex adds to each message before Action Mailbox sees it:
57
+
58
+ | Header | |
59
+ |---|---|
60
+ | `X-Original-To` | The address it was delivered to, which Action Mailbox routes on even when it is not in To or Cc |
61
+ | `X-Lettermint-Spam` | `yes` or `no`, Lettermint's verdict; nothing is dropped |
62
+ | `X-Lettermint-Spam-Score` | Lettermint's score |
63
+
64
+ ### How it behaves
65
+
66
+ - A wrong or missing signature, or one more than 5 minutes old, gets a `401`. Other events (deliveries, bounces) get a `204` and are ignored for now.
67
+ - The webhook answers once the download is queued. If the app cannot queue it, Lettermint gets a `500` and tries again.
68
+ - The job tries again for several hours when Lettermint cannot be reached, and gives up at once if the link has expired (28 days after the message arrived).
69
+ - A message that arrives twice is kept once: Action Mailbox ignores a source it already has.
70
+ - The links are secrets: the webhook's `data` is filtered from the request log and the job does not log its arguments.
71
+
72
+ ## Testing in an app
73
+
74
+ To test a mailbox, Action Mailbox's own `receive_inbound_email_from_mail` is the simplest. To test the webhook end to end:
75
+
76
+ ```ruby
77
+ require "telex/test_helper"
78
+
79
+ class InboundTest < ActionDispatch::IntegrationTest
80
+ include Telex::TestHelper
81
+
82
+ test "a reply reaches its quote" do
83
+ body = lettermint_inbound_payload(recipient: "reply+quote-12@reply.hq.box.paris")
84
+ post rails_lettermint_inbound_emails_path, params: body, headers: lettermint_webhook_headers(body)
85
+ # Telex.download = an object answering #fetch(url) with the message's source, to run the job without the network
86
+ end
87
+ end
88
+ ```
89
+
90
+ ## Not done yet
91
+
92
+ - Delivery events (bounces, complaints) are ignored. They would come through the same webhook.
93
+ - Whether Lettermint keeps the Message-ID Rails generates, which replies point to in `In-Reply-To`: Lettermint has an `X-LM-Preserve-Message-ID` header for it, to check against a real reply before relying on threading.
94
+
95
+ ## Development
96
+
97
+ ```sh
98
+ bundle install
99
+ bin/rails db:migrate
100
+ bin/rails test
101
+ ```
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,51 @@
1
+ module ActionMailbox
2
+ # Ingests inbound emails from Lettermint, whose webhooks carry a signed link to the original
3
+ # message rather than the message itself. The link is fetched by Telex::IngestJob.
4
+ #
5
+ # Authenticates requests by their X-Lettermint-Signature header.
6
+ #
7
+ # Returns:
8
+ #
9
+ # - <tt>204 No Content</tt> once the message is queued for download, and for every other event
10
+ # - <tt>401 Unauthorized</tt> if the signature is wrong or more than 5 minutes old
11
+ # - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails from Lettermint
12
+ # - <tt>422 Unprocessable Entity</tt> if an inbound event has no link to the message
13
+ # - <tt>500 Server Error</tt> if the webhook secret is missing, or the Active Job backend is unavailable
14
+ class Ingresses::Lettermint::InboundEmailsController < ActionMailbox::BaseController
15
+ before_action :authenticate
16
+
17
+ def create
18
+ if payload["event"] == "message.inbound"
19
+ data = payload.fetch("data", {})
20
+ url = data.dig("raw", "url")
21
+ return head :unprocessable_entity if url.blank?
22
+
23
+ Telex::IngestJob.perform_later(url, recipient: data["recipient"], spam_score: data["spam_score"], spam: data["is_spam"])
24
+ end
25
+
26
+ head :no_content
27
+ end
28
+
29
+ private
30
+ # The payload holds the message, and links Lettermint asks to keep secret: none of it goes to the logs.
31
+ def process_action(...)
32
+ request.set_header "action_dispatch.parameter_filter", Rails.application.config.filter_parameters + [ /\Adata\z/ ]
33
+ super
34
+ end
35
+
36
+ def payload
37
+ @payload ||= JSON.parse(request.raw_post)
38
+ end
39
+
40
+ def authenticate
41
+ head :unauthorized unless Telex::Signature.new(request.headers["X-Lettermint-Signature"], secret: secret).valid?(request.raw_post)
42
+ end
43
+
44
+ def secret
45
+ Telex.webhook_secret.presence || raise(ArgumentError, <<~MESSAGE.squish)
46
+ Missing the Lettermint webhook secret. Set lettermint.webhook_secret in your application's
47
+ encrypted credentials or provide the LETTERMINT_WEBHOOK_SECRET environment variable.
48
+ MESSAGE
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,34 @@
1
+ module Telex
2
+ # Downloads an inbound message and hands it to Action Mailbox. Lettermint retries webhooks,
3
+ # and Action Mailbox ignores a message it already has, so running twice is harmless.
4
+ #
5
+ # Not the host's ApplicationJob: none of its callbacks or retries apply here.
6
+ class IngestJob < ActiveJob::Base
7
+ queue_as :default
8
+
9
+ # The first argument is the signed link to the message.
10
+ self.log_arguments = false
11
+
12
+ retry_on Download::Error, wait: :polynomially_longer, attempts: 10
13
+
14
+ discard_on Download::Gone do |job, error|
15
+ Rails.logger.error "[Telex] Inbound email dropped: #{error.message} (job #{job.job_id})"
16
+ end
17
+
18
+ def perform(url, recipient: nil, spam_score: nil, spam: nil)
19
+ source = Telex.download.fetch(url)
20
+ ActionMailbox::InboundEmail.create_and_extract_message_id! headers(recipient, spam_score, spam) + source
21
+ end
22
+
23
+ private
24
+ # The address the message was delivered to, which may not be in its To or Cc (a Bcc, a list),
25
+ # is what Action Mailbox routes on. Lettermint's spam verdict comes along for mailboxes to use.
26
+ def headers(recipient, spam_score, spam)
27
+ lines = []
28
+ lines << "X-Original-To: #{recipient}" if recipient.present?
29
+ lines << "X-Lettermint-Spam: #{spam ? "yes" : "no"}" unless spam.nil?
30
+ lines << "X-Lettermint-Spam-Score: #{spam_score}" unless spam_score.nil?
31
+ lines.map { |line| "#{line}\r\n" }.join.b
32
+ end
33
+ end
34
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ Rails.application.routes.draw do
2
+ scope "/rails/action_mailbox", module: "action_mailbox/ingresses" do
3
+ post "/lettermint/inbound_emails" => "lettermint/inbound_emails#create", as: :rails_lettermint_inbound_emails
4
+ end
5
+ end
@@ -0,0 +1,41 @@
1
+ require "net/http"
2
+
3
+ module Telex
4
+ # Fetches a message's original source from the signed URL in the webhook. The URL needs no
5
+ # credentials, so it is a secret until it expires, 28 days after the message arrived.
6
+ class Download
7
+ # Worth trying again later.
8
+ class Error < StandardError; end
9
+
10
+ # The link has expired or was never valid: trying again will not help.
11
+ class Gone < Error; end
12
+
13
+ MAX_REDIRECTS = 3
14
+
15
+ def fetch(url, redirects: MAX_REDIRECTS)
16
+ uri = URI(url)
17
+ raise Gone, "Lettermint gave an address that is not HTTPS" unless uri.is_a?(URI::HTTPS)
18
+
19
+ case response = perform(uri)
20
+ when Net::HTTPSuccess
21
+ response.body.b
22
+ when Net::HTTPRedirection
23
+ raise Error, "Lettermint redirected too many times" if redirects.zero?
24
+ fetch(URI.join(uri, response["location"]).to_s, redirects: redirects - 1)
25
+ when Net::HTTPForbidden, Net::HTTPNotFound, Net::HTTPGone
26
+ raise Gone, "Lettermint answered #{response.code} for the message's source"
27
+ else
28
+ raise Error, "Lettermint answered #{response.code} for the message's source"
29
+ end
30
+ rescue Timeout::Error, SystemCallError, SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse => error
31
+ raise Error, "Lettermint could not be reached (#{error.class})"
32
+ end
33
+
34
+ private
35
+ def perform(uri)
36
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 60) do |http|
37
+ http.request(Net::HTTP::Get.new(uri))
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,12 @@
1
+ module Telex
2
+ # Not isolated: the ingress lives in Action Mailbox's namespace and its route in the app's,
3
+ # next to the ones Rails ships for other providers.
4
+ class Engine < ::Rails::Engine
5
+ # config.action_mailer.delivery_method = :lettermint
6
+ initializer "telex.delivery_method" do
7
+ ActiveSupport.on_load(:action_mailer) do
8
+ add_delivery_method :lettermint, Mail::SMTP, Telex::SMTP_SETTINGS.merge(password: Telex.api_token)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,31 @@
1
+ require "openssl"
2
+
3
+ module Telex
4
+ # The X-Lettermint-Signature header: "t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">",
5
+ # keyed with the webhook secret as given, whsec_ prefix included.
6
+ class Signature
7
+ TOLERANCE = 5.minutes
8
+
9
+ def self.generate(body, secret:, at: Time.now)
10
+ timestamp = at.to_i
11
+ "t=#{timestamp},v1=#{digest(timestamp, body, secret)}"
12
+ end
13
+
14
+ def self.digest(timestamp, body, secret)
15
+ OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{body}")
16
+ end
17
+
18
+ def initialize(header, secret:)
19
+ @parts = header.to_s.split(",").to_h { |part| part.split("=", 2).map(&:strip) }
20
+ @secret = secret
21
+ end
22
+
23
+ def valid?(body, now: Time.now)
24
+ timestamp = Integer(@parts["t"], exception: false)
25
+ return false unless timestamp && @parts["v1"]
26
+
27
+ (now.to_i - timestamp).abs <= TOLERANCE &&
28
+ ActiveSupport::SecurityUtils.secure_compare(@parts["v1"], self.class.digest(timestamp, body, @secret))
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,24 @@
1
+ module Telex
2
+ # Signs requests the way Lettermint does, for integration tests of the inbound webhook.
3
+ # To test a mailbox, Action Mailbox's own receive_inbound_email_from_mail is simpler.
4
+ module TestHelper
5
+ def lettermint_webhook_headers(body, secret: Telex.webhook_secret, event: JSON.parse(body)["event"], at: Time.now)
6
+ {
7
+ "Content-Type" => "application/json",
8
+ "X-Lettermint-Signature" => Telex::Signature.generate(body, secret: secret, at: at),
9
+ "X-Lettermint-Event" => event,
10
+ "X-Lettermint-Delivery" => at.to_i.to_s,
11
+ "X-Lettermint-Attempt" => "1"
12
+ }
13
+ end
14
+
15
+ def lettermint_inbound_payload(recipient:, raw_url: "https://storage.lettermint.co/inbound/raw/test?signature=test", **data)
16
+ {
17
+ id: SecureRandom.uuid,
18
+ event: "message.inbound",
19
+ timestamp: Time.now.utc.iso8601(3),
20
+ data: { recipient: recipient, raw: { url: raw_url, expires_at: 28.days.from_now.iso8601 }, is_spam: false, spam_score: 0.0, **data }
21
+ }.to_json
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,3 @@
1
+ module Telex
2
+ VERSION = "0.1.0"
3
+ end
data/lib/telex.rb ADDED
@@ -0,0 +1,35 @@
1
+ require "telex/version"
2
+ require "telex/signature"
3
+ require "telex/download"
4
+ require "telex/engine"
5
+
6
+ # Email for our Rails apps through Lettermint: Action Mailer sends over its SMTP relay,
7
+ # Action Mailbox receives from its inbound webhooks.
8
+ module Telex
9
+ SMTP_SETTINGS = {
10
+ address: "smtp.lettermint.co",
11
+ port: 587,
12
+ user_name: "lettermint",
13
+ authentication: :plain,
14
+ enable_starttls: true
15
+ }.freeze
16
+
17
+ class << self
18
+ # The project's API token, which is also the SMTP password.
19
+ def api_token
20
+ Rails.application.credentials.dig(:lettermint, :api_token) || ENV["LETTERMINT_API_TOKEN"]
21
+ end
22
+
23
+ # The inbound route's webhook secret, whsec_ prefix included.
24
+ def webhook_secret
25
+ Rails.application.credentials.dig(:lettermint, :webhook_secret) || ENV["LETTERMINT_WEBHOOK_SECRET"]
26
+ end
27
+
28
+ # Replaced in tests; anything that answers #fetch(url) with the message's source.
29
+ attr_writer :download
30
+
31
+ def download
32
+ @download ||= Download.new
33
+ end
34
+ end
35
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: telex
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - B.O.X
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ description: Action Mailer sends through Lettermint's SMTP relay; Action Mailbox receives
27
+ from Lettermint's inbound webhooks.
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ files:
32
+ - MIT-LICENSE
33
+ - README.md
34
+ - Rakefile
35
+ - app/controllers/action_mailbox/ingresses/lettermint/inbound_emails_controller.rb
36
+ - app/jobs/telex/ingest_job.rb
37
+ - config/routes.rb
38
+ - lib/telex.rb
39
+ - lib/telex/download.rb
40
+ - lib/telex/engine.rb
41
+ - lib/telex/signature.rb
42
+ - lib/telex/test_helper.rb
43
+ - lib/telex/version.rb
44
+ homepage: https://github.com/boxprod/telex
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ rubygems_mfa_required: 'true'
49
+ source_code_uri: https://github.com/boxprod/telex
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '3.3'
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ requirements: []
64
+ rubygems_version: 4.0.20
65
+ specification_version: 4
66
+ summary: Email for our Rails apps through Lettermint.
67
+ test_files: []