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