From c4c2d16342a7af00cb486aac8bb22915c176a24d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 25 Sep 2024 23:43:11 +0200 Subject: [PATCH 1/2] Encrypt outgoing emails when possible --- app/mailers/application_mailer.rb | 86 +++++++++++++++++++++++ app/mailers/custom_mailer.rb | 2 +- app/mailers/notification_mailer.rb | 8 +-- app/services/user_manager/pgp_encrypt.rb | 19 ++++++ spec/mailers/notification_mailer_spec.rb | 87 ++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 app/services/user_manager/pgp_encrypt.rb create mode 100644 spec/mailers/notification_mailer_spec.rb diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index d8bd387..a289acf 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,3 +1,89 @@ class ApplicationMailer < ActionMailer::Base layout 'mailer' + + private + + def send_mail + @template ||= "#{self.class.name.underscore}/#{caller[0][/`([^']*)'/, 1]}" + headers['Message-ID'] = message_id + + if @user.pgp_pubkey.present? + mail(to: @user.email, subject: "...", content_type: pgp_content_type) do |format| + format.text { render plain: pgp_content } + end + else + mail(to: @user.email, subject: @subject) do |format| + format.text { render @template } + end + end + end + + def from_address + ENV.fetch('SMTP_FROM_ADDRESS', 'accounts@localhost') + end + + def from_domain + Mail::Address.new(from_address).domain + end + + def message_id + @message_id ||= "#{SecureRandom.uuid}@#{from_domain}" + end + + def boundary + @boundary ||= SecureRandom.hex(8) + end + + def pgp_content_type + "multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"------------#{boundary}\"" + end + + def pgp_nested_content + message_content = render_to_string(template: @template) + message_content_base64 = Base64.encode64(message_content) + nested_boundary = SecureRandom.hex(8) + + <<~NESTED_CONTENT + Content-Type: multipart/mixed; boundary="------------#{nested_boundary}"; protected-headers="v1" + Subject: #{@subject} + From: <#{from_address}> + To: #{@user.display_name || @user.cn} <#{@user.email}> + Message-ID: <#{message_id}> + + --------------#{nested_boundary} + Content-Type: text/plain; charset=UTF-8; format=flowed + Content-Transfer-Encoding: base64 + + #{message_content_base64} + + --------------#{nested_boundary}-- + NESTED_CONTENT + end + + def pgp_content + encrypted_content = UserManager::PgpEncrypt.call(user: @user, text: pgp_nested_content) + encrypted_base64 = Base64.encode64(encrypted_content.to_s) + + <<~EMAIL_CONTENT + This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156) + --------------#{boundary} + Content-Type: application/pgp-encrypted + Content-Description: PGP/MIME version identification + + Version: 1 + + --------------#{boundary} + Content-Type: application/octet-stream; name="encrypted.asc" + Content-Description: OpenPGP encrypted message + Content-Disposition: inline; filename="encrypted.asc" + + -----BEGIN PGP MESSAGE----- + + #{encrypted_base64} + + -----END PGP MESSAGE----- + + --------------#{boundary}-- + EMAIL_CONTENT + end end diff --git a/app/mailers/custom_mailer.rb b/app/mailers/custom_mailer.rb index 325a620..fd35185 100644 --- a/app/mailers/custom_mailer.rb +++ b/app/mailers/custom_mailer.rb @@ -18,6 +18,6 @@ class CustomMailer < ApplicationMailer @user = params[:user] @subject = params[:subject] @body = params[:body] - mail(to: @user.email, subject: @subject) + send_mail end end diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index b4ec37d..d281380 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -3,7 +3,7 @@ class NotificationMailer < ApplicationMailer @user = params[:user] @amount_sats = params[:amount_sats] @subject = "Sats received" - mail to: @user.email, subject: @subject + send_mail end def remotestorage_auth_created @@ -15,19 +15,19 @@ class NotificationMailer < ApplicationMailer "#{access} #{directory}" end @subject = "New app connected to your storage" - mail to: @user.email, subject: @subject + send_mail end def new_invitations_available @user = params[:user] @subject = "New invitations added to your account" - mail to: @user.email, subject: @subject + send_mail end def bitcoin_donation_confirmed @user = params[:user] @donation = params[:donation] @subject = "Donation confirmed" - mail to: @user.email, subject: @subject + send_mail end end diff --git a/app/services/user_manager/pgp_encrypt.rb b/app/services/user_manager/pgp_encrypt.rb new file mode 100644 index 0000000..afb24b3 --- /dev/null +++ b/app/services/user_manager/pgp_encrypt.rb @@ -0,0 +1,19 @@ +require 'gpgme' + +module UserManager + class PgpEncrypt < UserManagerService + def initialize(user:, text:) + @user = user + @text = text + end + + def call + crypto = GPGME::Crypto.new + crypto.encrypt( + @text, + recipients: @user.gnupg_key, + always_trust: true + ) + end + end +end diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb new file mode 100644 index 0000000..a2b3099 --- /dev/null +++ b/spec/mailers/notification_mailer_spec.rb @@ -0,0 +1,87 @@ +# spec/mailers/welcome_mailer_spec.rb +require 'rails_helper' + +RSpec.describe NotificationMailer, type: :mailer do + describe '#lightning_sats_received' do + + context "without PGP key" do + let(:user) { create(:user, cn: "phil", email: 'phil@example.com') } + + before do + allow_any_instance_of(User).to receive(:ldap_entry).and_return({ + uid: user.cn, ou: user.ou, display_name: nil, pgp_key: nil + }) + end + + describe "unencrypted email" do + let(:mail) { described_class.with(user: user, amount_sats: 21000).lightning_sats_received } + + it 'renders the correct to/from headers' do + expect(mail.to).to eq([user.email]) + expect(mail.from).to eq(['accounts@kosmos.org']) + end + + it 'renders the correct subject' do + expect(mail.subject).to eq('Sats received') + end + + it 'uses the correct content type' do + expect(mail.header['content-type'].to_s).to include('text/plain') + end + + it 'renders the body with correct content' do + expect(mail.body.encoded).to match(/You just received 21,000 sats/) + expect(mail.body.encoded).to include(user.address) + end + + it 'includes a link to the lightning service page' do + expect(mail.body.encoded).to include("https://accounts.kosmos.org/services/lightning") + end + end + end + + context "with PGP key" do + let(:pgp_pubkey) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") } + let(:pgp_fingerprint) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" } + let(:user) { create(:user, id: 2, cn: "alice", email: 'alice@example.com', pgp_fpr: pgp_fingerprint) } + + before do + allow_any_instance_of(User).to receive(:ldap_entry).and_return({ + uid: user.cn, ou: user.ou, display_name: nil, pgp_key: pgp_pubkey + }) + end + + describe "encrypted email" do + let(:mail) { described_class.with(user: user, amount_sats: 21000).lightning_sats_received } + + it 'renders the correct to/from headers' do + expect(mail.to).to eq([user.email]) + expect(mail.from).to eq(['accounts@kosmos.org']) + end + + it 'encrypts the subject line' do + expect(mail.subject).to eq('...') + end + + it 'uses the correct content type' do + expect(mail.header['content-type'].to_s).to include('multipart/encrypted') + expect(mail.header['content-type'].to_s).to include('protocol="application/pgp-encrypted"') + end + + it 'renders the PGP version part' do + expect(mail.body.encoded).to include("Content-Type: application/pgp-encrypted") + expect(mail.body.encoded).to include("Content-Description: PGP/MIME version identification") + expect(mail.body.encoded).to include("Version: 1") + end + + it 'renders the encrypted PGP part' do + expect(mail.body.encoded).to include('Content-Type: application/octet-stream; name="encrypted.asc"') + expect(mail.body.encoded).to include('Content-Description: OpenPGP encrypted message') + expect(mail.body.encoded).to include('Content-Disposition: inline; filename="encrypted.asc"') + expect(mail.body.encoded).to include('-----BEGIN PGP MESSAGE-----') + expect(mail.body.encoded).to include('hF4DR') + end + end + end + end +end From 339462f3205fa41c9e4344735dd5589907698fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 8 Oct 2024 14:06:10 +0200 Subject: [PATCH 2/2] Refactor mailer options usage --- app/mailers/application_mailer.rb | 3 ++- config/environments/development.rb | 16 +++++++++++----- config/environments/production.rb | 2 +- config/environments/test.rb | 8 ++++++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index a289acf..a9ccecf 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,5 @@ class ApplicationMailer < ActionMailer::Base + default Rails.application.config.action_mailer.default_options layout 'mailer' private @@ -19,7 +20,7 @@ class ApplicationMailer < ActionMailer::Base end def from_address - ENV.fetch('SMTP_FROM_ADDRESS', 'accounts@localhost') + self.class.default[:from] end def from_domain diff --git a/config/environments/development.rb b/config/environments/development.rb index 9f73578..a5b46ac 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -57,16 +57,22 @@ Rails.application.configure do # routes, locales, etc. This feature depends on the listen gem. config.file_watcher = ActiveSupport::EventedFileUpdateChecker - config.action_mailer.default_options = { - from: "accounts@localhost" - } - # Don't actually send emails, cache them for viewing via letter opener config.action_mailer.delivery_method = :letter_opener + # Don't care if the mailer can't send config.action_mailer.raise_delivery_errors = true + # Base URL to be used by email template link helpers - config.action_mailer.default_url_options = { host: "localhost:3000", protocol: "http" } + config.action_mailer.default_url_options = { + host: "localhost:3000", + protocol: "http" + } + + config.action_mailer.default_options = { + from: "accounts@localhost", + message_id: -> { "<#{Mail.random_tag}@localhost>" }, + } # Allow requests from any IP config.web_console.permissions = '0.0.0.0/0' diff --git a/config/environments/production.rb b/config/environments/production.rb index 16e7fa5..da0fa6f 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -63,7 +63,7 @@ Rails.application.configure do outgoing_email_domain = Mail::Address.new(outgoing_email_address).domain config.action_mailer.default_url_options = { - host: ENV['AKKOUNTS_DOMAIN'], + host: ENV.fetch('AKKOUNTS_DOMAIN'), protocol: "https", } diff --git a/config/environments/test.rb b/config/environments/test.rb index 65473dd..0c474bf 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -46,8 +46,12 @@ Rails.application.configure do config.action_mailer.default_url_options = { host: "accounts.kosmos.org", - protocol: "https", - from: "accounts@kosmos.org" + protocol: "https" + } + + config.action_mailer.default_options = { + from: "accounts@kosmos.org", + message_id: -> { "<#{Mail.random_tag}@kosmos.org>" }, } config.active_job.queue_adapter = :test