letter_opener_web_wally 2.0.1

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.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/brakeman-analysis.yml +36 -0
  3. data/.github/workflows/main.yml +29 -0
  4. data/.github/workflows/release-gem.yml +32 -0
  5. data/.gitignore +20 -0
  6. data/.rspec +4 -0
  7. data/.rubocop.yml +29 -0
  8. data/.rubocop_todo.yml +19 -0
  9. data/CHANGELOG.md +95 -0
  10. data/Gemfile +8 -0
  11. data/LICENSE.txt +0 -0
  12. data/README.md +176 -0
  13. data/Rakefile +10 -0
  14. data/app/controllers/letter_opener_web/application_controller.rb +7 -0
  15. data/app/controllers/letter_opener_web/letters_controller.rb +77 -0
  16. data/app/models/letter_opener_web/aws_letter.rb +98 -0
  17. data/app/models/letter_opener_web/base_letter.rb +93 -0
  18. data/app/models/letter_opener_web/letter.rb +54 -0
  19. data/app/models/letter_opener_web/s3_message.rb +25 -0
  20. data/app/views/layouts/letter_opener_web/_javascripts.html.erb +31 -0
  21. data/app/views/layouts/letter_opener_web/_styles.html.erb +3 -0
  22. data/app/views/layouts/letter_opener_web/js/_favcount.html.erb +104 -0
  23. data/app/views/layouts/letter_opener_web/js/_jquery.html.erb +7 -0
  24. data/app/views/layouts/letter_opener_web/letters.html.erb +15 -0
  25. data/app/views/layouts/letter_opener_web/styles/_bootstrap.html.erb +9 -0
  26. data/app/views/layouts/letter_opener_web/styles/_icon.html.erb +2 -0
  27. data/app/views/layouts/letter_opener_web/styles/_letters.html.erb +70 -0
  28. data/app/views/letter_opener_web/letters/_item.html.erb +10 -0
  29. data/app/views/letter_opener_web/letters/index.html.erb +24 -0
  30. data/bin/setup +6 -0
  31. data/config/routes.rb +9 -0
  32. data/letter_opener_web.gemspec +35 -0
  33. data/lib/letter_opener_web/delivery_method.rb +29 -0
  34. data/lib/letter_opener_web/engine.rb +20 -0
  35. data/lib/letter_opener_web/version.rb +5 -0
  36. data/lib/letter_opener_web.rb +52 -0
  37. data/script/pre-push +2 -0
  38. data/spec/controllers/letter_opener_web/letters_controller_spec.rb +282 -0
  39. data/spec/dummy/Rakefile +8 -0
  40. data/spec/dummy/app/assets/config/manifest.js +3 -0
  41. data/spec/dummy/app/assets/images/.keep +0 -0
  42. data/spec/dummy/app/assets/stylesheets/application.css +15 -0
  43. data/spec/dummy/app/channels/application_cable/channel.rb +6 -0
  44. data/spec/dummy/app/channels/application_cable/connection.rb +6 -0
  45. data/spec/dummy/app/controllers/application_controller.rb +4 -0
  46. data/spec/dummy/app/controllers/concerns/.keep +0 -0
  47. data/spec/dummy/app/helpers/application_helper.rb +4 -0
  48. data/spec/dummy/app/javascript/packs/application.js +15 -0
  49. data/spec/dummy/app/jobs/application_job.rb +9 -0
  50. data/spec/dummy/app/mailers/application_mailer.rb +6 -0
  51. data/spec/dummy/app/models/application_record.rb +5 -0
  52. data/spec/dummy/app/models/concerns/.keep +0 -0
  53. data/spec/dummy/app/views/layouts/application.html.erb +15 -0
  54. data/spec/dummy/app/views/layouts/mailer.html.erb +13 -0
  55. data/spec/dummy/app/views/layouts/mailer.text.erb +1 -0
  56. data/spec/dummy/bin/rails +4 -0
  57. data/spec/dummy/bin/rake +4 -0
  58. data/spec/dummy/bin/setup +33 -0
  59. data/spec/dummy/config/application.rb +37 -0
  60. data/spec/dummy/config/boot.rb +7 -0
  61. data/spec/dummy/config/environment.rb +7 -0
  62. data/spec/dummy/config/environments/development.rb +78 -0
  63. data/spec/dummy/config/environments/production.rb +122 -0
  64. data/spec/dummy/config/environments/test.rb +61 -0
  65. data/spec/dummy/config/initializers/application_controller_renderer.rb +10 -0
  66. data/spec/dummy/config/initializers/assets.rb +14 -0
  67. data/spec/dummy/config/initializers/backtrace_silencers.rb +10 -0
  68. data/spec/dummy/config/initializers/content_security_policy.rb +30 -0
  69. data/spec/dummy/config/initializers/cookies_serializer.rb +7 -0
  70. data/spec/dummy/config/initializers/filter_parameter_logging.rb +8 -0
  71. data/spec/dummy/config/initializers/inflections.rb +18 -0
  72. data/spec/dummy/config/initializers/mime_types.rb +6 -0
  73. data/spec/dummy/config/initializers/permissions_policy.rb +13 -0
  74. data/spec/dummy/config/initializers/wrap_parameters.rb +16 -0
  75. data/spec/dummy/config/locales/en.yml +33 -0
  76. data/spec/dummy/config/puma.rb +45 -0
  77. data/spec/dummy/config/routes.rb +5 -0
  78. data/spec/dummy/config.ru +8 -0
  79. data/spec/dummy/lib/assets/.keep +0 -0
  80. data/spec/dummy/log/.keep +0 -0
  81. data/spec/dummy/public/404.html +67 -0
  82. data/spec/dummy/public/422.html +67 -0
  83. data/spec/dummy/public/500.html +66 -0
  84. data/spec/dummy/public/apple-touch-icon-precomposed.png +0 -0
  85. data/spec/dummy/public/apple-touch-icon.png +0 -0
  86. data/spec/dummy/public/favicon.ico +0 -0
  87. data/spec/dummy/storage/.keep +0 -0
  88. data/spec/letter_opener_web_spec.rb +47 -0
  89. data/spec/models/letter_opener_web/aws_letter_spec.rb +196 -0
  90. data/spec/models/letter_opener_web/letter_spec.rb +184 -0
  91. data/spec/rails_helper.rb +8 -0
  92. data/spec/spec_helper.rb +8 -0
  93. metadata +291 -0
@@ -0,0 +1,66 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>We're sorry, but something went wrong (500)</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <style>
7
+ .rails-default-error-page {
8
+ background-color: #EFEFEF;
9
+ color: #2E2F30;
10
+ text-align: center;
11
+ font-family: arial, sans-serif;
12
+ margin: 0;
13
+ }
14
+
15
+ .rails-default-error-page div.dialog {
16
+ width: 95%;
17
+ max-width: 33em;
18
+ margin: 4em auto 0;
19
+ }
20
+
21
+ .rails-default-error-page div.dialog > div {
22
+ border: 1px solid #CCC;
23
+ border-right-color: #999;
24
+ border-left-color: #999;
25
+ border-bottom-color: #BBB;
26
+ border-top: #B00100 solid 4px;
27
+ border-top-left-radius: 9px;
28
+ border-top-right-radius: 9px;
29
+ background-color: white;
30
+ padding: 7px 12% 0;
31
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
32
+ }
33
+
34
+ .rails-default-error-page h1 {
35
+ font-size: 100%;
36
+ color: #730E15;
37
+ line-height: 1.5em;
38
+ }
39
+
40
+ .rails-default-error-page div.dialog > p {
41
+ margin: 0 0 1em;
42
+ padding: 1em;
43
+ background-color: #F7F7F7;
44
+ border: 1px solid #CCC;
45
+ border-right-color: #999;
46
+ border-left-color: #999;
47
+ border-bottom-color: #999;
48
+ border-bottom-left-radius: 4px;
49
+ border-bottom-right-radius: 4px;
50
+ border-top-color: #DADADA;
51
+ color: #666;
52
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
53
+ }
54
+ </style>
55
+ </head>
56
+
57
+ <body class="rails-default-error-page">
58
+ <!-- This file lives in public/500.html -->
59
+ <div class="dialog">
60
+ <div>
61
+ <h1>We're sorry, but something went wrong.</h1>
62
+ </div>
63
+ <p>If you are the application owner check the logs for more information.</p>
64
+ </div>
65
+ </body>
66
+ </html>
File without changes
File without changes
File without changes
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ RSpec.describe LetterOpenerWeb do
6
+ subject { described_class }
7
+ after(:each) { described_class.reset! }
8
+
9
+ describe '.config' do
10
+ it 'sets default letters_storage' do
11
+ expect(subject.config.letters_storage).to eq(:local)
12
+ end
13
+
14
+ it 'sets default letters_location' do
15
+ expect(subject.config.letters_location).to eq(Rails.root.join('tmp', 'letter_opener'))
16
+ end
17
+ end
18
+
19
+ describe '.configure' do
20
+ it 'yields config to the block' do
21
+ subject.configure do |config|
22
+ expect(config).to eq(subject.config)
23
+ end
24
+ end
25
+
26
+ it 'retains settings set within the block' do
27
+ subject.configure do |config|
28
+ config.letters_location = 'tmp/test_path'
29
+ end
30
+
31
+ expect(subject.config.letters_location).to eq('tmp/test_path')
32
+ end
33
+ end
34
+
35
+ describe '.reset!' do
36
+ it 'resets configuration' do
37
+ subject.configure do |config|
38
+ config.letters_location = 'tmp/test_path'
39
+ end
40
+
41
+ subject.reset!
42
+
43
+ expected = Rails.root.join('tmp', 'letter_opener')
44
+ expect(subject.config.letters_location).to eq(expected)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe LetterOpenerWeb::AwsLetter do
4
+ subject { described_class.new(id: letter_id) }
5
+
6
+ let(:aws_access_key_id) { 'aws_access_key_id' }
7
+ let(:aws_secret_access_key) { 'aws_secret_access_key' }
8
+ let(:aws_region) { 'aws_region' }
9
+ let(:aws_bucket) { 'aws_bucket' }
10
+ let(:letter_id) { 'letter_id' }
11
+ let(:letters_location) { nil }
12
+
13
+ let(:s3_client) do
14
+ instance_double(Aws::S3::Client)
15
+ end
16
+
17
+ before :each do
18
+ LetterOpenerWeb.configure do |config|
19
+ config.letters_storage = :s3
20
+ config.letters_location = letters_location
21
+ config.aws_access_key_id = aws_access_key_id
22
+ config.aws_secret_access_key = aws_secret_access_key
23
+ config.aws_region = aws_region
24
+ config.aws_bucket = aws_bucket
25
+ end
26
+
27
+ allow(Aws::S3::Client)
28
+ .to receive(:new)
29
+ .with(
30
+ access_key_id: aws_access_key_id,
31
+ secret_access_key: aws_secret_access_key,
32
+ region: aws_region
33
+ )
34
+ .and_return(s3_client)
35
+ end
36
+
37
+ after :each do
38
+ LetterOpenerWeb.reset!
39
+ end
40
+
41
+ describe '#letters_location' do
42
+ subject { described_class.letters_location }
43
+
44
+ context 'with a nil given value in LetterOpenerWeb.configure' do
45
+ let(:letters_location) { nil }
46
+
47
+ it { is_expected.to eq(nil) }
48
+ end
49
+
50
+ context 'with any value in LetterOpenerWeb.configure' do
51
+ let(:letters_location) { 'letters_location' }
52
+
53
+ it { is_expected.to eq(letters_location) }
54
+ end
55
+ end
56
+
57
+ describe '#search' do
58
+ it 'returns all letters', aggregates_failure: true do
59
+ expect(s3_client)
60
+ .to receive(:list_objects_v2)
61
+ .with(bucket: aws_bucket, prefix: '', delimiter: '.html')
62
+ .and_return(
63
+ instance_double(Aws::S3::Types::ListObjectsV2Output, common_prefixes: [
64
+ instance_double(Aws::S3::Types::CommonPrefix,
65
+ prefix: 'location/to/letters/1111_1111/toto.html'),
66
+ instance_double(Aws::S3::Types::CommonPrefix, prefix: '2222_2222/toto.html')
67
+ ])
68
+ )
69
+
70
+ letters = described_class.search
71
+
72
+ expect(letters.size).to eq(2)
73
+ expect(letters.first.id).to eq('2222_2222')
74
+ expect(letters.last.id).to eq('1111_1111')
75
+ end
76
+ end
77
+
78
+ describe '#destroy_all' do
79
+ let(:s3_content) { instance_double(Aws::S3::Types::Object, key: '123') }
80
+
81
+ it 'removes all letters', aggregates_failure: true do
82
+ expect(s3_client)
83
+ .to receive(:list_objects_v2)
84
+ .with(bucket: aws_bucket, prefix: '')
85
+ .and_return(
86
+ instance_double(Aws::S3::Types::ListObjectsV2Output, contents: [s3_content])
87
+ )
88
+ expect(s3_client)
89
+ .to receive(:delete_objects)
90
+ .with(
91
+ bucket: aws_bucket,
92
+ delete: {
93
+ objects: [key: s3_content.key],
94
+ quiet: false
95
+ }
96
+ )
97
+
98
+ described_class.destroy_all
99
+ end
100
+ end
101
+
102
+ describe '.delete' do
103
+ let(:s3_content) { instance_double(Aws::S3::Types::Object, key: '123') }
104
+
105
+ it 'does not remove letter if it does not exist', aggregates_failure: true do
106
+ expect(s3_client)
107
+ .to receive(:list_objects_v2)
108
+ .with(bucket: aws_bucket, prefix: letter_id)
109
+ .and_return(
110
+ instance_double(Aws::S3::Types::ListObjectsV2Output, contents: [])
111
+ )
112
+ expect(s3_client)
113
+ .not_to receive(:delete_objects)
114
+
115
+ subject.delete
116
+ end
117
+
118
+ it 'removes letter if it exists' do
119
+ expect(s3_client)
120
+ .to receive(:list_objects_v2)
121
+ .with(bucket: aws_bucket, prefix: letter_id)
122
+ .and_return(
123
+ instance_double(Aws::S3::Types::ListObjectsV2Output, contents: [s3_content])
124
+ )
125
+ .twice
126
+
127
+ expect(s3_client)
128
+ .to receive(:delete_objects)
129
+ .with(
130
+ bucket: aws_bucket,
131
+ delete: {
132
+ objects: [key: s3_content.key],
133
+ quiet: false
134
+ }
135
+ )
136
+
137
+ subject.delete
138
+ end
139
+ end
140
+
141
+ describe '.valid?' do
142
+ it 'returns true if the letter exists' do
143
+ expect(s3_client)
144
+ .to receive(:list_objects_v2)
145
+ .with(bucket: aws_bucket, prefix: letter_id)
146
+ .and_return(
147
+ instance_double(Aws::S3::Types::ListObjectsV2Output, contents: [
148
+ instance_double(Aws::S3::Types::Object)
149
+ ])
150
+ )
151
+
152
+ expect(subject).to be_valid
153
+ end
154
+
155
+ it 'returns false if the letter does not exist' do
156
+ expect(s3_client)
157
+ .to receive(:list_objects_v2)
158
+ .with(bucket: aws_bucket, prefix: letter_id)
159
+ .and_return(
160
+ instance_double(Aws::S3::Types::ListObjectsV2Output, contents: [])
161
+ )
162
+
163
+ expect(subject).not_to be_valid
164
+ end
165
+ end
166
+
167
+ describe '.attachments' do
168
+ let(:s3_content) { instance_double(Aws::S3::Types::Object, key: '123') }
169
+ let(:presigned_url) { 'https://presigned-url' }
170
+ let(:s3_bucket) { instance_double(Aws::S3::Bucket, object: double) }
171
+ let(:s3_object) { instance_double(Aws::S3::Object, presigned_url: double) }
172
+
173
+ it 'returns attachments with presigned urls' do
174
+ expect(s3_client)
175
+ .to receive(:list_objects_v2)
176
+ .with(bucket: aws_bucket, prefix: "#{letter_id}/attachments/")
177
+ .and_return(
178
+ instance_double(Aws::S3::Types::ListObjectsV2Output, contents: [s3_content])
179
+ )
180
+ expect(Aws::S3::Bucket)
181
+ .to receive(:new)
182
+ .with(name: aws_bucket, client: s3_client)
183
+ .and_return(s3_bucket)
184
+ expect(s3_bucket)
185
+ .to receive(:object)
186
+ .with(s3_content.key)
187
+ .and_return(s3_object)
188
+ expect(s3_object)
189
+ .to receive(:presigned_url)
190
+ .with(:get, expires_in: 1.week.to_i)
191
+ .and_return(presigned_url)
192
+
193
+ expect(subject.attachments).to eq(s3_content.key => presigned_url)
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe LetterOpenerWeb::Letter do
4
+ let(:location) { Pathname.new(__dir__).join('..', '..', 'tmp').cleanpath }
5
+
6
+ def rich_text(mail_id)
7
+ <<~MAIL
8
+ Rich text for #{mail_id}
9
+ <!DOCTYPE html>
10
+ <body>
11
+ <div id="container">
12
+ <div id="message_headers">
13
+ <dl>
14
+ <dt>From:</dt>
15
+ <dd>noreply@example.com</dd>
16
+ </dl>
17
+ </div>
18
+
19
+ <a href='a-link.html'>
20
+ <img src='an-image.jpg'>
21
+ Link text
22
+ </a>
23
+ <a href='fooo.html'>Bar</a>
24
+ <a href="example.html" class="blank"></a>
25
+ <address><a href="inside-address.html">inside address</a></address>
26
+ </div>
27
+ </body>
28
+ MAIL
29
+ end
30
+
31
+ before :each do
32
+ LetterOpenerWeb.configure { |config| config.letters_location = location }
33
+
34
+ %w[1111_1111 2222_2222].each do |folder|
35
+ FileUtils.mkdir_p("#{location}/#{folder}")
36
+ File.write("#{location}/#{folder}/plain.html", "Plain text for #{folder}")
37
+ File.write("#{location}/#{folder}/rich.html", rich_text(folder))
38
+ FileUtils.mkdir_p("#{Rails.root.join('tmp', 'letter_opener')}/#{folder}")
39
+ File.write("#{Rails.root.join('tmp', 'letter_opener')}/#{folder}/rich.html", "Rich text for #{folder}")
40
+ end
41
+ end
42
+
43
+ after :each do
44
+ LetterOpenerWeb.reset!
45
+ FileUtils.rm_rf(location)
46
+ end
47
+
48
+ describe 'rich text headers' do
49
+ let(:id) { '1111_1111' }
50
+ subject { described_class.new(id:).headers }
51
+
52
+ before do
53
+ FileUtils.rm_rf("#{location}/#{id}/plain.html")
54
+ end
55
+
56
+ it { is_expected.to match(%r{<dl>\s*<dt>From:</dt>\s*<dd>noreply@example\.com</dd>}m) }
57
+ end
58
+
59
+ describe 'plain text headers' do
60
+ let(:id) { '1111_1111' }
61
+ subject { described_class.new(id:).headers }
62
+
63
+ before do
64
+ FileUtils.rm_rf("#{location}/#{id}/rich.html")
65
+ end
66
+
67
+ it { is_expected.to eq('UNABLE TO PARSE HEADERS') }
68
+ end
69
+
70
+ describe 'rich text version' do
71
+ let(:id) { '1111_1111' }
72
+ subject { described_class.new(id:).rich_text }
73
+
74
+ it { is_expected.to match(/Rich text for 1111_1111/) }
75
+
76
+ it 'changes links to show up on a new window' do
77
+ link_html = [
78
+ "<a href='a-link.html' target='_blank'>",
79
+ " <img src='an-image.jpg'/>",
80
+ ' Link text',
81
+ '</a>'
82
+ ].join("\n ")
83
+
84
+ expect(subject).to include(link_html)
85
+ end
86
+
87
+ it 'always rewrites links with a closing tag rather than making them selfclosing' do
88
+ expect(subject).to include("<a class='blank' href='example.html' target='_blank'></a>")
89
+ end
90
+ end
91
+
92
+ describe 'plain text version' do
93
+ let(:id) { '2222_2222' }
94
+ subject { described_class.new(id:).plain_text }
95
+
96
+ it { is_expected.to match(/Plain text for 2222_2222/) }
97
+ end
98
+
99
+ describe 'default style' do
100
+ let(:id) { '2222_2222' }
101
+ subject { described_class.new(id:) }
102
+
103
+ it 'returns rich if rich text version is present' do
104
+ expect(subject.default_style).to eq('rich')
105
+ end
106
+
107
+ it 'returns plain if rich text version is not present' do
108
+ allow(File).to receive_messages(exist?: false)
109
+ expect(subject.default_style).to eq('plain')
110
+ end
111
+ end
112
+
113
+ describe 'attachments' do
114
+ let(:file) { 'an-image.csv' }
115
+ let(:attachments_dir) { "#{location}/#{id}/attachments" }
116
+ let(:id) { '1111_1111' }
117
+
118
+ subject { described_class.new(id:) }
119
+
120
+ before do
121
+ FileUtils.mkdir_p(attachments_dir)
122
+ File.open("#{attachments_dir}/#{file}", 'w') { |f| f.puts 'csv,contents' }
123
+ end
124
+
125
+ it 'builds a hash with file name as key and full path as value' do
126
+ expect(subject.attachments).to eq(file => "#{attachments_dir}/#{file}")
127
+ end
128
+ end
129
+
130
+ describe '.search' do
131
+ let(:search_results) { described_class.search }
132
+ let(:first_letter) { search_results.first }
133
+ let(:last_letter) { search_results.last }
134
+
135
+ before do
136
+ allow(File).to receive(:mtime).with("#{location}/1111_1111").and_return(Date.today - 1.day)
137
+ allow(File).to receive(:mtime).with("#{location}/2222_2222").and_return(Date.today)
138
+ end
139
+
140
+ it 'returns a list of ordered letters' do
141
+ expect(first_letter.sent_at).to be > last_letter.sent_at
142
+ end
143
+ end
144
+
145
+ describe '.find' do
146
+ let(:id) { 'an-id' }
147
+ let(:letter) { described_class.find(id) }
148
+
149
+ it 'returns a letter with id set' do
150
+ expect(letter.id).to eq(id)
151
+ end
152
+ end
153
+
154
+ describe '.destroy_all' do
155
+ it 'removes all letters' do
156
+ described_class.destroy_all
157
+ expect(Dir["#{location}/**/*"]).to be_empty
158
+ end
159
+ end
160
+
161
+ describe '#delete' do
162
+ let(:id) { '1111_1111' }
163
+
164
+ subject { described_class.new(id:).delete }
165
+
166
+ it 'removes the letter with given id' do
167
+ subject
168
+
169
+ directories = Dir["#{location}/*"]
170
+ expect(directories.count).to eql(1)
171
+ expect(directories.first).not_to match(id)
172
+ end
173
+
174
+ context 'when the id is outside of the letters base path' do
175
+ let(:id) { '../3333_3333' }
176
+
177
+ it 'does not remove the letter' do
178
+ expect(FileUtils).not_to receive(:rm_rf).with(location.join(id).cleanpath.to_s)
179
+
180
+ expect(subject).to be_nil
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV['RAILS_ENV'] ||= 'test'
4
+ require File.expand_path('dummy/config/environment', __dir__)
5
+ require 'spec_helper'
6
+ require 'rspec/rails'
7
+
8
+ RSpec.configure(&:infer_spec_type_from_file_location!)
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shoulda-matchers'
4
+
5
+ RSpec.configure do |config|
6
+ config.filter_run focus: true
7
+ config.run_all_when_everything_filtered = true
8
+ end