postfix_admin 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,6 +1,7 @@
1
1
  require 'yaml'
2
2
  require 'postfix_admin'
3
3
  require 'postfix_admin/doveadm'
4
+ require 'terminal-table'
4
5
 
5
6
  module PostfixAdmin
6
7
  class CLI
@@ -29,55 +30,49 @@ module PostfixAdmin
29
30
  name = name.downcase if name
30
31
 
31
32
  if name =~ /@/
33
+ # address like argument
32
34
  if Admin.exists?(name)
33
- show_admin_details(name)
34
- end
35
-
36
- if Mailbox.exists?(name)
37
- show_account_details(name)
35
+ # admin
36
+ show_admin_details(name, display_password: true)
37
+ puts
38
+ show_admin_domain(name)
39
+ elsif Mailbox.exists?(name)
40
+ # mailbox
41
+ show_account_details(name, display_password: true)
38
42
  elsif Alias.exists?(name)
43
+ # alias
39
44
  show_alias_details(name)
45
+ else
46
+ raise Error, "Could not find admin/mailbox/alias #{name}"
40
47
  end
41
48
 
42
49
  return
43
50
  end
44
51
 
45
52
  show_summary(name)
53
+ puts
46
54
 
47
55
  if name
48
- show_admin(name)
49
- show_address(name)
50
- show_alias(name)
56
+ # domain name
57
+ show_domain_details(name)
51
58
  else
59
+ # no argument: show all domains and admins
52
60
  show_domain
61
+ puts
53
62
  show_admin
54
63
  end
55
64
  end
56
65
 
57
66
  def show_summary(domain_name = nil)
58
- title = "Summary"
59
67
  if domain_name
60
- domain_name = domain_name.downcase
61
- domain_check(domain_name)
62
- title = "Summary of #{domain_name}"
63
- end
64
-
65
- report(title) do
66
- if domain_name
67
- domain = Domain.find(domain_name)
68
- puts "Mailboxes : %4d / %4s" % [domain.rel_mailboxes.count, max_str(domain.mailboxes)]
69
- puts "Aliases : %4d / %4s" % [domain.pure_aliases.count, max_str(domain.aliases)]
70
- puts "Max Quota : %4d MB" % domain.maxquota
71
- puts "Active : %3s" % domain.active_str
72
- else
73
- puts "Domains : %4d" % Domain.without_all.count
74
- puts "Admins : %4d" % Admin.count
75
- puts "Mailboxes : %4d" % Mailbox.count
76
- puts "Aliases : %4d" % Alias.pure.count
77
- end
68
+ show_domain_summary(domain_name)
69
+ else
70
+ show_general_summary
78
71
  end
79
72
  end
80
73
 
74
+ # Set up a domain
75
+ # Add a domain, add an admin, and grant the admin access to the domain
81
76
  def setup_domain(domain_name, password)
82
77
  admin = "admin@#{domain_name}"
83
78
  add_domain(domain_name)
@@ -85,62 +80,75 @@ module PostfixAdmin
85
80
  add_admin_domain(admin, domain_name)
86
81
  end
87
82
 
88
- def show_account_details(user_name)
83
+ def show_account_details(user_name, display_password: false)
89
84
  account_check(user_name)
90
85
  mailbox = Mailbox.find(user_name)
91
86
  mail_alias = Alias.find(user_name)
92
87
 
93
- report("Mailbox") do
94
- puts "Address : %s" % mailbox.username
95
- puts "Name : %s" % mailbox.name
96
- puts "Password : %s" % mailbox.password
97
- puts "Quota : %d MB" % max_str(mailbox.quota / KB_TO_MB)
98
- puts "Go to : %s" % mail_alias.goto
99
- puts "Active : %s" % mailbox.active_str
100
- end
88
+ rows = []
89
+ puts_title("Mailbox")
90
+ rows << ["Address", mailbox.username]
91
+ rows << ["Name", mailbox.name]
92
+ rows << ["Password", mailbox.password] if display_password
93
+ rows << ["Quota (MB)", mailbox.quota_mb_str]
94
+ rows << ["Go to", mail_alias.goto]
95
+ rows << ["Active", mailbox.active_str]
96
+
97
+ puts_table(rows: rows)
101
98
  end
102
99
 
103
- def show_admin_details(name)
100
+ def show_admin_details(name, display_password: false)
104
101
  admin_check(name)
105
102
  admin = Admin.find(name)
106
103
 
107
- report("Admin") do
108
- puts "Name : %s" % admin.username
109
- puts "Password : %s" % admin.password
110
- puts "Domains : %s" % (admin.super_admin? ? "ALL" : admin.rel_domains.count)
111
- puts "Role : %s" % (admin.super_admin? ? "Super admin" : "Admin")
112
- puts "Active : %s" % admin.active_str
113
- end
104
+ rows = []
105
+ puts_title("Admin")
106
+ rows << ["Name", admin.username]
107
+ rows << ["Password", admin.password] if display_password
108
+ rows << ["Domains", admin.super_admin? ? "ALL" : admin.rel_domains.count.to_s]
109
+ rows << ["Role", admin.super_admin? ? "Super Admin" : "Standard Admin"]
110
+ rows << ["Active", admin.active_str]
111
+
112
+ puts_table(rows: rows)
114
113
  end
115
114
 
116
115
  def show_alias_details(name)
117
116
  alias_check(name)
118
117
  mail_alias = Alias.find(name)
119
- report("Alias") do
120
- puts "Address : %s" % mail_alias.address
121
- puts "Go to : %s" % mail_alias.goto
122
- puts "Active : %s" % mail_alias.active_str
123
- end
118
+
119
+ rows = []
120
+ puts_title("Alias")
121
+ rows << ["Address", mail_alias.address]
122
+ rows << ["Go to", mail_alias.goto]
123
+ rows << ["Active", mail_alias.active_str]
124
+
125
+ puts_table(rows: rows)
124
126
  end
125
127
 
126
128
  def show_domain
127
- index = " No. Domain Aliases Mailboxes Quota (MB) Active"
128
- report('Domains', index) do
129
- if Domain.without_all.empty?
130
- puts " No domains"
131
- next
132
- end
129
+ rows = []
130
+ headings = ["No.", "Domain", "Aliases", "Mailboxes","Max Quota (MB)",
131
+ "Active", "Description"]
133
132
 
134
- Domain.without_all.each_with_index do |d, i|
135
- puts "%4d %-30s %3d /%3s %3d /%3s %10d %-3s" %
136
- [i+1, d.domain, d.pure_aliases.count, max_str(d.aliases),
137
- d.rel_mailboxes.count, max_str(d.mailboxes), d.maxquota, d.active_str]
138
- end
133
+ puts_title("Domains")
134
+ if Domain.without_all.empty?
135
+ puts "No domains"
136
+ return
137
+ end
138
+
139
+ Domain.without_all.each_with_index do |d, i|
140
+ no = i + 1
141
+ aliases_str = "%4d / %4s" % [d.pure_aliases.count, d.aliases_str]
142
+ mailboxes_str = "%4d / %4s" % [d.rel_mailboxes.count, d.mailboxes_str]
143
+ rows << [no.to_s, d.domain, aliases_str, mailboxes_str,
144
+ d.maxquota_str, d.active_str, d.description]
139
145
  end
146
+
147
+ puts_table(headings: headings, rows: rows)
140
148
  end
141
149
 
142
- def add_domain(domain_name)
143
- @base.add_domain(domain_name)
150
+ def add_domain(domain_name, description: nil)
151
+ @base.add_domain(domain_name, description: description)
144
152
  puts_registered(domain_name, "a domain")
145
153
  end
146
154
 
@@ -174,6 +182,7 @@ module PostfixAdmin
174
182
  domain.mailboxes = options[:mailboxes] if options[:mailboxes]
175
183
  domain.maxquota = options[:maxquota] if options[:maxquota]
176
184
  domain.active = options[:active] unless options[:active].nil?
185
+ domain.description = options[:description] if options[:description]
177
186
  domain.save!
178
187
 
179
188
  puts "Successfully updated #{domain_name}"
@@ -187,36 +196,44 @@ module PostfixAdmin
187
196
 
188
197
  def show_admin(domain_name = nil)
189
198
  admins = domain_name ? Admin.select { |a| a.rel_domains.exists?(domain_name) } : Admin.all
190
- index = " No. Admin Domains Active"
191
- report("Admins", index) do
192
- if admins.empty?
193
- puts " No admins"
194
- next
195
- end
199
+ headings = %w[No. Admin Domains Active]
196
200
 
197
- admins.each_with_index do |a, i|
198
- domains = a.super_admin? ? 'Super admin' : a.rel_domains.count
199
- puts "%4d %-40s %11s %-3s" % [i+1, a.username, domains, a.active_str]
200
- end
201
+ puts_title("Admins")
202
+ if admins.empty?
203
+ puts "No admins"
204
+ return
201
205
  end
206
+
207
+ rows = []
208
+ admins.each_with_index do |a, i|
209
+ no = i + 1
210
+ domains = a.super_admin? ? 'Super Admin' : a.rel_domains.count
211
+ rows << [no.to_s, a.username, domains.to_s, a.active_str]
212
+ end
213
+
214
+ puts_table(headings: headings, rows: rows)
202
215
  end
203
216
 
204
217
  def show_address(domain_name)
205
218
  domain_check(domain_name)
206
219
 
220
+ rows = []
207
221
  mailboxes = Domain.find(domain_name).rel_mailboxes
208
- index = " No. Email Name Quota (MB) Active Maildir"
209
- report("Addresses", index) do
210
- if mailboxes.empty?
211
- puts " No addresses"
212
- next
213
- end
222
+ headings = ["No.", "Email", "Name", "Quota (MB)", "Active", "Maildir"]
214
223
 
215
- mailboxes.each_with_index do |m, i|
216
- quota = m.quota.to_f/ KB_TO_MB.to_f
217
- puts "%4d %-30s %-20s %10s %-3s %s" % [i+1, m.username, m.name, max_str(quota.to_i), m.active_str, m.maildir]
218
- end
224
+ puts_title("Addresses")
225
+ if mailboxes.empty?
226
+ puts "No addresses"
227
+ return
228
+ end
229
+
230
+ mailboxes.each_with_index do |m, i|
231
+ no = i + 1
232
+ rows << [no.to_s, m.username, m.name, m.quota_mb_str,
233
+ m.active_str, m.maildir]
219
234
  end
235
+
236
+ puts_table(headings: headings, rows: rows)
220
237
  end
221
238
 
222
239
  def show_alias(domain_name)
@@ -229,21 +246,24 @@ module PostfixAdmin
229
246
  end
230
247
 
231
248
  show_alias_base("Forwards", forwards)
249
+ puts
232
250
  show_alias_base("Aliases", aliases)
233
251
  end
234
252
 
235
253
  def show_admin_domain(user_name)
236
254
  admin = Admin.find(user_name)
255
+ puts_title("Admin Domains (#{user_name})")
237
256
  if admin.rel_domains.empty?
238
- puts "\nNo domain in database"
257
+ puts "\nNo domains for #{user_name}"
239
258
  return
240
259
  end
241
260
 
242
- report("Domains (#{user_name})", " No. Domain") do
243
- admin.rel_domains.each_with_index do |d, i|
244
- puts "%4d %-30s" % [i + 1, d.domain]
245
- end
261
+ rows = []
262
+ admin.rel_domains.each_with_index do |d, i|
263
+ no = i + 1
264
+ rows << [no.to_s, d.domain]
246
265
  end
266
+ puts_table(rows: rows, headings: %w[No. Domain])
247
267
  end
248
268
 
249
269
  def add_admin(user_name, password, super_admin = false, scheme = nil)
@@ -271,9 +291,8 @@ module PostfixAdmin
271
291
  def add_account(address, password, scheme = nil, name = nil)
272
292
  validate_password(password)
273
293
 
274
- @base.add_account(address, hashed_password(password, scheme), name)
294
+ @base.add_account(address, hashed_password(password, scheme), name: name)
275
295
  puts_registered(address, "an account")
276
- show_account_details(address)
277
296
  end
278
297
 
279
298
  def add_alias(address, goto)
@@ -325,11 +344,25 @@ module PostfixAdmin
325
344
  puts_deleted(address)
326
345
  end
327
346
 
328
- def log
329
- Log.all.each do |l|
330
- time = l.timestamp.strftime("%Y-%m-%d %X %Z")
331
- puts "#{time} #{l.username} #{l.domain} #{l.action} #{l.data}"
347
+ def log(domain: nil, last: nil)
348
+ headings = %w[Timestamp Admin Domain Action Data]
349
+ rows = []
350
+
351
+ logs = if domain
352
+ Log.where(domain: domain)
353
+ else
354
+ Log.all
355
+ end
356
+
357
+ logs = logs.last(last) if last
358
+
359
+ logs.each do |l|
360
+ # TODO: Consider if zone should be included ('%Z').
361
+ time = l.timestamp.strftime("%Y-%m-%d %X")
362
+ rows << [time, l.username, l.domain, l.action, l.data]
332
363
  end
364
+
365
+ puts_table(headings: headings, rows: rows)
333
366
  end
334
367
 
335
368
  def dump
@@ -366,17 +399,58 @@ module PostfixAdmin
366
399
 
367
400
  private
368
401
 
402
+ def show_general_summary
403
+ rows = []
404
+ title = "Summary"
405
+ rows << ["Domains", Domain.without_all.count]
406
+ rows << ["Admins", Admin.count]
407
+ rows << ["Mailboxes", Mailbox.count]
408
+ rows << ["Aliases", Alias.pure.count]
409
+
410
+ puts_title(title)
411
+ puts_table(rows: rows)
412
+ end
413
+
414
+ def show_domain_summary(domain_name)
415
+ domain_name = domain_name.downcase
416
+ domain_check(domain_name)
417
+
418
+ rows = []
419
+ domain = Domain.find(domain_name)
420
+ rows << ["Mailboxes", "%4d / %4s" % [domain.rel_mailboxes.count, domain.mailboxes_str]]
421
+ rows << ["Aliases", "%4d / %4s" % [domain.pure_aliases.count, domain.aliases_str]]
422
+ rows << ["Max Quota (MB)", domain.maxquota_str]
423
+ rows << ["Active", domain.active_str]
424
+ rows << ["Description", domain.description]
425
+
426
+ puts_title(domain_name)
427
+ puts_table(rows: rows)
428
+ end
429
+
430
+ def show_domain_details(domain_name)
431
+ show_admin(domain_name)
432
+ puts
433
+ show_address(domain_name)
434
+ puts
435
+ show_alias(domain_name)
436
+ end
437
+
369
438
  def show_alias_base(title, addresses)
370
- report(title, " No. Address Active Go to") do
371
- if addresses.empty?
372
- puts " No #{title.downcase}"
373
- next
374
- end
439
+ rows = []
440
+ puts_title(title)
375
441
 
376
- addresses.each_with_index do |a, i|
377
- puts "%4d %-40s %-3s %s" % [i+1, a.address, a.active_str, a.goto]
378
- end
442
+ if addresses.empty?
443
+ puts "No #{title.downcase}"
444
+ return
379
445
  end
446
+
447
+ headings = ["No.", "Address", "Active", "Go to"]
448
+ addresses.each_with_index do |a, i|
449
+ no = i + 1
450
+ rows << [no.to_s, a.address, a.active_str, a.goto]
451
+ end
452
+
453
+ puts_table(headings: headings, rows: rows)
380
454
  end
381
455
 
382
456
  def puts_registered(name, as_str)
@@ -388,7 +462,7 @@ module PostfixAdmin
388
462
  end
389
463
 
390
464
  def config_file
391
- config_file = File.expand_path(CLI.config_file)
465
+ File.expand_path(CLI.config_file)
392
466
  end
393
467
 
394
468
  def load_config
@@ -409,17 +483,12 @@ module PostfixAdmin
409
483
  File.chmod(0600, file)
410
484
  end
411
485
 
412
- def print_line
413
- puts "-"*120
486
+ def puts_table(args)
487
+ puts Terminal::Table.new(args)
414
488
  end
415
489
 
416
- def report(title, index = nil)
417
- puts "\n[#{title}]"
418
- print_line if index
419
- puts index if index
420
- print_line
421
- yield
422
- print_line
490
+ def puts_title(title)
491
+ puts "| #{title} |"
423
492
  end
424
493
 
425
494
  def account_check(user_name)
@@ -469,23 +538,9 @@ module PostfixAdmin
469
538
  end
470
539
  end
471
540
 
472
- def max_str(value)
473
- case value
474
- when 0
475
- '--'
476
- when -1
477
- '0'
478
- else
479
- value.to_s
480
- end
481
- end
482
-
483
- private
484
-
485
541
  def hashed_password(password, in_scheme = nil)
486
542
  prefix = @base.config[:passwordhash_prefix]
487
543
  scheme = in_scheme || @base.config[:scheme]
488
- puts "scheme: #{scheme}"
489
544
  PostfixAdmin::Doveadm.password(password, scheme, prefix)
490
545
  end
491
546
  end
@@ -12,7 +12,6 @@ module DovecotCramMD5Password
12
12
  end
13
13
 
14
14
  attr_reader :password_unencrypted
15
- attr_accessor :password_unencrypted_confirmation
16
15
  end
17
16
 
18
17
  def password_unencrypted=(unencrypted_password)
@@ -8,91 +8,82 @@ module PostfixAdmin
8
8
  message: "must be a valid domain name" }
9
9
  validates :transport, presence: true
10
10
 
11
+ # max aliases (Disabled: -1, Unlimited: 0)
11
12
  validates :aliases, presence: true,
12
13
  numericality: { only_integer: true,
13
- greater_than_or_equal_to: 0 }
14
+ greater_than_or_equal_to: -1 }
15
+ # max mailboxes (Disabled: -1, Unlimited: 0)
14
16
  validates :mailboxes, presence: true,
15
17
  numericality: { only_integer: true,
16
- greater_than_or_equal_to: 0 }
18
+ greater_than_or_equal_to: -1 }
19
+
20
+ # max quota (MB) for each mailbox (Unlimited: 0)
21
+ # It's not sure what 'disabled' means for max quota.
22
+ # So it's better not to allow users to set `maxquota` to -1.
17
23
  validates :maxquota, presence: true,
18
24
  numericality: { only_integer: true,
19
25
  greater_than_or_equal_to: 0 }
20
26
 
27
+ # mailboxes that belong to this domain
21
28
  has_many :rel_mailboxes, class_name: "Mailbox", foreign_key: :domain,
22
29
  dependent: :destroy
30
+ # aliases that belong to this domain
23
31
  has_many :rel_aliases, class_name: "Alias", foreign_key: :domain,
24
32
  dependent: :destroy
25
33
 
34
+ # It causes errors to set `dependent: :destroy` as other columns
35
+ # because the domain_admins table doesn't have a single primary key.
36
+ #
37
+ # PostfixAdmin::DomainAdmin Load (0.5ms) SELECT `domain_admins`.* FROM `domain_admins` WHERE `domain_admins`.`domain` = 'example.com'
38
+ # PostfixAdmin::DomainAdmin Destroy (1.1ms) DELETE FROM `domain_admins` WHERE `domain_admins`.`` IS NULL
39
+ #
40
+ # ActiveRecord::StatementInvalid: Mysql2::Error: Unknown column 'domain_admins.' in 'where clause'
41
+ # from /usr/local/bundle/gems/mysql2-0.5.4/lib/mysql2/client.rb:148:in `_query'
42
+ # Caused by Mysql2::Error: Unknown column 'domain_admins.' in 'where clause'
43
+ # from /usr/local/bundle/gems/mysql2-0.5.4/lib/mysql2/client.rb:148:in `_query'
44
+ #
45
+ # It works well with `dependent: :delete_all` instead.
46
+ #
47
+ # PostfixAdmin::DomainAdmin Destroy (0.4ms) DELETE FROM `domain_admins` WHERE `domain_admins`.`domain` = 'example.com'
26
48
  has_many :domain_admins, foreign_key: :domain, dependent: :delete_all
49
+
27
50
  has_many :admins, through: :domain_admins
28
51
 
29
52
  before_validation do |domain|
30
- domain.domain = domain.domain.downcase unless domain.domain.empty?
53
+ domain.domain = domain.domain&.downcase
31
54
  domain.transport = "virtual"
32
55
  end
33
56
 
34
57
  scope :without_all, -> { where.not(domain: "ALL") }
35
58
 
59
+ # aliases that don't belong to a mailbox
36
60
  def pure_aliases
37
61
  rel_aliases.pure
38
62
  end
39
63
 
40
- def aliases_unlimited?
41
- aliases.zero?
42
- end
43
-
44
- def mailboxes_unlimited?
45
- mailboxes.zero?
46
- end
47
-
48
64
  def aliases_str
49
- num_str(aliases)
65
+ max_num_str(aliases)
50
66
  end
51
67
 
52
68
  def mailboxes_str
53
- num_str(mailboxes)
54
- end
55
-
56
- def aliases_short_str
57
- num_short_str(aliases)
58
- end
59
-
60
- def mailboxes_short_str
61
- num_short_str(mailboxes)
69
+ max_num_str(mailboxes)
62
70
  end
63
71
 
64
72
  def maxquota_str
65
- if maxquota.zero?
66
- "Unlimited"
67
- else
68
- "#{maxquota} MB"
69
- end
70
- end
71
-
72
- def maxquota_short_str
73
- if maxquota.zero?
74
- "--"
75
- else
76
- "#{maxquota} MB"
77
- end
73
+ max_num_str(maxquota)
78
74
  end
79
75
 
80
76
  private
81
77
 
82
- def num_str(num)
83
- if num.zero?
78
+ def max_num_str(num)
79
+ case num
80
+ when -1
81
+ "Disabled"
82
+ when 0
84
83
  "Unlimited"
85
84
  else
86
85
  num.to_s
87
86
  end
88
87
  end
89
-
90
- def num_short_str(num)
91
- if num.zero?
92
- "--"
93
- else
94
- num.to_s
95
- end
96
- end
97
88
  end
98
89
  end
@@ -11,8 +11,9 @@ module PostfixAdmin
11
11
 
12
12
  def self.password(in_password, in_scheme, prefix)
13
13
  password = Shellwords.escape(in_password)
14
- scheme = Shellwords.escape(in_scheme)
15
- stdin, stdout, stderr = Open3.popen3("#{self.command_name} -s #{scheme} -p #{password}")
14
+ scheme = Shellwords.escape(in_scheme)
15
+ _stdin, stdout, stderr = Open3.popen3("#{self.command_name} -s #{scheme} -p #{password}")
16
+
16
17
  if stderr.readlines.to_s =~ /Fatal:/
17
18
  raise Error, stderr.readlines
18
19
  else
@@ -26,12 +27,7 @@ module PostfixAdmin
26
27
  end
27
28
 
28
29
  def self.command_name
29
- begin
30
- Open3.capture3("doveadm pw -l")[2].exited?
31
- "doveadm pw"
32
- rescue Errno::ENOENT
33
- "dovecotpw"
34
- end
30
+ "doveadm pw"
35
31
  end
36
32
  end
37
33
  end
@@ -12,9 +12,13 @@ module PostfixAdmin
12
12
  message: "must be a valid email address" }
13
13
  validates :maildir, presence: true, uniqueness: { case_sensitive: false }
14
14
  validates :local_part, presence: true
15
+
16
+ # quota (KB)
15
17
  validates :quota, presence: true,
16
18
  numericality: { only_integer: true,
17
19
  greater_than_or_equal_to: 0 }
20
+
21
+ # quota (MB), which actually doesn't exist in DB
18
22
  validates :quota_mb, presence: true,
19
23
  numericality: { only_integer: true,
20
24
  greater_than_or_equal_to: 0 }
@@ -63,7 +67,7 @@ module PostfixAdmin
63
67
  mailbox.quota = 0
64
68
  end
65
69
  mailbox.username = "#{mailbox.local_part}@#{mailbox.domain}"
66
- mailbox.maildir = "#{mailbox.domain}/#{mailbox.username}/"
70
+ mailbox.maildir ||= "#{mailbox.domain}/#{mailbox.username}/"
67
71
  mailbox.build_alias(local_part: mailbox.local_part, goto: mailbox.username,
68
72
  domain: mailbox.domain)
69
73
  end
@@ -77,12 +81,16 @@ module PostfixAdmin
77
81
  end
78
82
  end
79
83
 
80
- def quota_str
81
- if quota.zero?
82
- "--"
84
+ def quota_mb_str
85
+ case quota
86
+ when -1
87
+ # It's not sure what 'disabled' means for quota.
88
+ "Disabled"
89
+ when 0
90
+ "Unlimited"
83
91
  else
84
- quota_mb = quota / KB_TO_MB
85
- "#{quota_mb} MB"
92
+ mb_size = quota / KB_TO_MB
93
+ mb_size.to_s
86
94
  end
87
95
  end
88
96
  end