bulkmail 0.1 → 0.3.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.
data/CHANGELOG CHANGED
@@ -1,3 +1,31 @@
1
+ *0.3.0*
2
+
3
+ * Connecting directly to the recipient's SMTP server.
4
+
5
+ * Implemented Sender class that does the actual work.
6
+
7
+ * Implemented Message and Message::MultiPart classes for composing messages.
8
+
9
+ * Rewritten from the ground up.
10
+
11
+ *0.2.1*
12
+
13
+ * Added shuffling of addresses.
14
+
15
+ * Fixed batch slicing to actually work.
16
+
17
+ *0.2*
18
+
19
+ * Updated README.
20
+
21
+ * Set batch size at 10.
22
+
23
+ * Fixed script to use new version.
24
+
25
+ * Removed actionmailer dependancy.
26
+
27
+ * Rewrote to use Net::SMTP directly.
28
+
1
29
  *0.1*
2
30
 
3
31
  * Add INT trap.
@@ -22,4 +50,4 @@
22
50
 
23
51
  * Preliminary content.
24
52
 
25
- * Created basic project structure.
53
+ * Created basic project structure.
data/README CHANGED
@@ -1,6 +1,6 @@
1
- = about bulkmail
1
+ = About Bulkmail
2
2
 
3
- Bulkmail is a simple bulk mailing utility in Ruby. It takes a content file and a recipients file, and sends the content to each address listed in the recipients file. I wrote it to be as simple as possible, and as a basic alternative to commercial bulk-mailers. The free ones suck.
3
+ Bulkmail is a simple bulk mailing utility in Ruby. It takes a content file and a recipients file, and sends the content to each address listed in the recipients file. Bulkmail sends email by connecting directly to each recipient's SMTP server, and you therefore do not need your own SMTP server in order to send messages.
4
4
 
5
5
  == Usage
6
6
 
@@ -11,32 +11,29 @@ Bulkmail is a simple bulk mailing utility in Ruby. It takes a content file and a
11
11
  where the options are:
12
12
  [--recipients, -r] recipients file name
13
13
  [--content, -c] content file name
14
- [--subject, -s] subject
15
14
  [--from, -f] from
16
- [--host, -h] smtp hostname (if none is specified localhost is presumed)
17
- [--user, -u] user name (if authentication is needed)
18
- [--password, -p] password (if authentication is needed)
19
- [--auth, -a] authentication type
20
- [--mime, -m] message mime type.
15
+ [--helo, -h] SMTP HELO domain (most SMTP servers require you to identify with your domain)
21
16
 
22
- You can also include headers in the content file, provided they are followed by a blank line. For example:
17
+ You should include headers in the content file. Remember that the headers should be followed by a blank link, followed by the message body. For example:
23
18
 
24
- Subject: Baroque Concert this Thursday
19
+ Subject: My New Thing
20
+ From: myemail@mydomain.com
21
+ Content-Type: text/html; charset=UTF-8
25
22
 
26
- Hello Baroque Lovers,
27
- A special concert is about to take place this Thursday at...
23
+ <html><body><h1>My New Thing!</h1></body></html>
28
24
 
29
25
  === From Ruby
30
26
 
31
- BulkMail.send(options)
32
-
33
- Valid options are:
34
- [:recipients_file] recipients file name
35
- [:content_file] content file name
36
- [:subject] subject
37
- [:from] from
38
- [:additional_headers] additional headers
39
-
40
- You can set server settings in the same fashion as ActionMailer by using the convenience method:
41
-
42
- BulkMail.server_settings[:address] = 'mail.example.com'
27
+ msg = BulkMail::Message({
28
+ :from => 'myemail@mydomain.com',
29
+ :subject => 'nothing really'
30
+ }, 'hi there')
31
+
32
+ sender = BulkMail::Sender.new({
33
+ :list => addresses,
34
+ :from => 'myemail@mydomain.com',
35
+ :helo => 'mydomain.com',
36
+ :message => msg
37
+ })
38
+
39
+ sender.start
data/Rakefile CHANGED
@@ -7,7 +7,7 @@ require 'fileutils'
7
7
  include FileUtils
8
8
 
9
9
  NAME = "bulkmail"
10
- VERS = "0.1"
10
+ VERS = "0.3.0"
11
11
  CLEAN.include ['**/.*.sw?', '*.gem', '.config']
12
12
  RDOC_OPTS = ['--quiet', '--title', "Bulkmail Documentation",
13
13
  "--opname", "index.html",
@@ -26,7 +26,7 @@ Rake::RDocTask.new do |rdoc|
26
26
  rdoc.options += RDOC_OPTS
27
27
  rdoc.main = "README"
28
28
  rdoc.title = "Bulkmail Documentation"
29
- rdoc.rdoc_files.add ['README', 'CHANGELOG', 'COPYING', 'lib/*.rb']
29
+ rdoc.rdoc_files.add ['README', 'CHANGELOG', 'COPYING', 'lib/**/*']
30
30
  end
31
31
 
32
32
  spec = Gem::Specification.new do |s|
@@ -44,9 +44,10 @@ spec = Gem::Specification.new do |s|
44
44
  s.homepage = 'http://code.google.com/p/bulkmail/'
45
45
  s.executables = ['bulkmail']
46
46
 
47
- s.add_dependency('actionmailer')
48
47
  s.required_ruby_version = '>= 1.8.2'
49
48
 
49
+ s.add_dependency('pNet-DNS')
50
+
50
51
  s.files = %w(COPYING README Rakefile) + Dir.glob("{bin,doc,lib}/**/*")
51
52
 
52
53
  s.require_path = "lib"
data/bin/bulkmail CHANGED
@@ -1,10 +1,16 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
+ STDERR.reopen "/dev/null", "a"
4
+ # We do this because pNet-DNS throws out a bunch of warnings
5
+
3
6
  require 'rubygems'
4
7
  require 'optparse'
8
+ require 'bulkmail'
5
9
 
6
- config = {
7
- }
10
+ $helo = nil
11
+ $from = nil
12
+ $recipients = []
13
+ $content = nil
8
14
 
9
15
  opts = OptionParser.new do |opts|
10
16
  opts.banner = "Usage: bulkmail <options>"
@@ -12,42 +18,22 @@ opts = OptionParser.new do |opts|
12
18
  opts.separator ""
13
19
  opts.separator "Options:"
14
20
 
15
- opts.on("-h", "--host HOSTNAME", "SMPT host name. Default is localhost.") do |v|
16
- config[:host] = v
21
+ opts.on("-h", "--helo HOSTNAME", "HELO host name.") do |v|
22
+ $helo = v
17
23
  end
18
24
 
19
25
  opts.on("-r", "--recipients filename", "Recipients file name") do |v|
20
- config[:recipients_file] = v
26
+ $recipients = IO.readlines(v).map {|l| l =~ /(.*)(\r\n|\n)$/ ? $1 : l}.compact
21
27
  end
22
28
 
23
29
  opts.on("-c", "--content filename", "Content file name") do |v|
24
- config[:content_file] = v
25
- end
26
-
27
- opts.on("-s", "--subject subject", "Subject line") do |v|
28
- config[:subject] = v
30
+ $content = IO.read(v)
29
31
  end
30
32
 
31
33
  opts.on("-f", "--from from", "From address") do |v|
32
- config[:from] = v
33
- end
34
-
35
- opts.on("-u", "--user username", "User name (for authentication)") do |v|
36
- config[:user] = v
37
- end
38
-
39
- opts.on("-p", "--password password", "Password (for authentication)") do |v|
40
- config[:password] = v
34
+ $from = v
41
35
  end
42
36
 
43
- opts.on("-a", "--authentication type", "Authentication type") do |v|
44
- config[:authentication] = v
45
- end
46
-
47
- opts.on("-m", "--mime type", "MIME type") do |v|
48
- config[:content_type] = v
49
- end
50
-
51
37
  # No argument, shows at tail. This will print an options summary.
52
38
  # Try it and see!
53
39
  opts.on_tail("-?", "--help", "Show this message") do
@@ -68,24 +54,24 @@ begin
68
54
  opts.parse! ARGV
69
55
  rescue => e
70
56
  puts e.message
57
+ puts e.backtrace.first
71
58
  exit
72
59
  end
73
60
 
74
- unless config[:recipients_file] && config[:content_file]
61
+ unless $content
75
62
  puts opts
76
63
  exit
77
64
  end
78
65
 
79
- require 'bulkmail'
80
-
81
- BulkMail.server_settings[:address] = config[:host] if config[:host]
82
- BulkMail.server_settings[:user_name] = config[:user] if config[:user]
83
- BulkMail.server_settings[:password] = config[:password] if config[:password]
84
- BulkMail.server_settings[:authentication] = config[:authentication].to_sym if config[:authentication]
66
+ trap('INT') {exit}
85
67
 
86
- p BulkMail.server_settings
68
+ puts "Please hold on..."
87
69
 
88
- job = BulkMail.send(config)
89
- trap('INT') {job.thread.exit;}
90
- job.thread.join
91
- puts "Message sent to #{job.delivered} of #{job.total} recipients."
70
+ sender = BulkMail::Sender.new({
71
+ :list => $recipients,
72
+ :from => $from,
73
+ :message => $content,
74
+ :helo => $helo
75
+
76
+ })
77
+ sender.start
@@ -0,0 +1 @@
1
+ Mon Nov 06 23:13:08 +0200 2006
@@ -0,0 +1,18 @@
1
+ require 'rubygems'
2
+ require 'net/smtp'
3
+ require 'net/dns'
4
+
5
+ module BulkMail
6
+ # Performs a DNS query for a domain's MX records and returns an array
7
+ # of SMTP servers for the domain ordered by preference. If no records
8
+ # are found an error is raised.
9
+ def self.smtp_servers_for_domain(domain)
10
+ res = Net::DNS::Resolver.new
11
+ mx = Net::DNS.mx(domain, res, 'IN')
12
+ if mx
13
+ mx.sort {|x,y| y.preference <=> x.preference}.map {|rr| rr.exchange}
14
+ else
15
+ raise RuntimeError, "Could not locate MX records for domain #{domain}."
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,103 @@
1
+ # Symbol extensions
2
+ class Symbol
3
+ # Returns the symbol as a header string
4
+ def to_header
5
+ return @to_header if @to_header
6
+ s = to_s
7
+ @to_header = (s.upcase == s) ? s :
8
+ to_s.split('_').map{|w| w.capitalize}.join('-')
9
+ end
10
+ end
11
+
12
+ class String
13
+ alias_method :to_header, :to_s
14
+ end
15
+
16
+ module BulkMail
17
+ # The Message class can be used to compose email messages with headers. Usage
18
+ # is pretty straightforward:
19
+ #
20
+ # m = BulkMail::Message.new({:from => my_email, :subject => 'hello'},
21
+ # 'Hi there!')
22
+ class Message
23
+ attr_accessor :headers, :body
24
+
25
+ DEFAULT_HEADERS = {
26
+ :mime_version => '1.0'
27
+ }
28
+
29
+ PLAIN = 'text/plain; charset=UTF-8'.freeze
30
+ HTML = 'text/html; charset=UTF-8'.freeze
31
+
32
+ # Creates a new message, including the default headers in the message headers.
33
+ def initialize(headers, body)
34
+ @headers = DEFAULT_HEADERS.merge(headers)
35
+ @body = body
36
+ end
37
+
38
+ LINE_BREAK = "\r\n"
39
+ HEADER_FMT = "%s: %s\r\n".freeze
40
+
41
+ # Formats the message headers into a string.
42
+ def self.format_headers(headers)
43
+ headers.inject('') do |m, kv|
44
+ if kv[1]
45
+ m << (HEADER_FMT % [kv[0].to_header, kv[1]])
46
+ else
47
+ m
48
+ end
49
+ end
50
+ end
51
+
52
+ # Composes the header and body into a message string.
53
+ def to_s
54
+ msg = Message.format_headers(@headers)
55
+ msg << LINE_BREAK
56
+ msg << body
57
+ msg
58
+ end
59
+ end
60
+
61
+ # The MultiPart class can be used for composing multi-part messages. For
62
+ # example:
63
+ #
64
+ # m = BulkMail::Message::MultiPart.new(
65
+ # {:from => 'my_email', :subject => 'Hello'}, :alternate)
66
+ # m.add_part({:content_type => BulkMail::Message::PLAIN},
67
+ # 'You are now seeing the plain text version')
68
+ # m.add_part({:content_type => BulkMail::Message::HTML},
69
+ # '<p>You are now seeing the HTML version</p>')
70
+ # puts m.to_s
71
+ class Message::MultiPart < Message
72
+ attr_accessor :parts, :multi_type
73
+
74
+ # Initializes a new instance.
75
+ def initialize(headers, multi_type)
76
+ super(headers, nil)
77
+ @multi_type = multi_type
78
+ @parts = []
79
+ end
80
+
81
+ # Adds a part to the message.
82
+ def add_part(headers, body)
83
+ @parts << Message.new(headers, body)
84
+ end
85
+
86
+ BOUNDARY = '!!@@##$$'.freeze
87
+ MULTI_PART_CONTENT_TYPE_FMT = "multipart/%s; charset=UTF-8; boundary=\"#{BOUNDARY}\"".freeze
88
+ BOUNDARY_LINE = "\r\n--#{BOUNDARY}\r\n".freeze
89
+
90
+ # Composes the parts into an email message.
91
+ def to_s
92
+ @headers[:content_type] = MULTI_PART_CONTENT_TYPE_FMT % @multi_type
93
+ msg = Message.format_headers(@headers)
94
+ @parts.each do |part|
95
+ msg << BOUNDARY_LINE
96
+ msg << part.to_s
97
+ end
98
+ msg << BOUNDARY_LINE
99
+ msg
100
+ end
101
+ end
102
+ end
103
+
@@ -0,0 +1,90 @@
1
+ require 'net/smtp'
2
+
3
+ module BulkMail
4
+ # Sender instances perform the actual work of sending an email. Each sender
5
+ # sets up a thread pool (by default with up to 20 threads) for performing the
6
+ # work. When creating a Sender instance, a hash of options is supplied:
7
+ #
8
+ # BulkMail::Sender.new({
9
+ # :list => addresses,
10
+ # :from => my_address,
11
+ # :helo => my_helo_domain,
12
+ # :concurrent => 50, # size of thread pool
13
+ # :message => message
14
+ # })
15
+ #
16
+ # To start sending, invoke Sender#start. The start method will block until
17
+ # all addresses have been processed.
18
+ class Sender
19
+ # Initializes a new instance with the supplied options hash, and creates a
20
+ # thread pool (with a default maximum size of 20).
21
+ def initialize(opts)
22
+ @opts = opts
23
+ @pool = ThreadPool.new(@opts[:concurrent] || 20)
24
+ @smtp_servers = {}
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ # Starts processing the addresses by sending each one a message, and waits
29
+ # for the thread pool to finish its work.
30
+ def start
31
+ @opts[:list].each {|a| @pool.process {send(a)}}
32
+ @pool.join
33
+ end
34
+
35
+ # returns an array of SMTP servers for the email address. If an invalid
36
+ # address is specified, an error is raised. SMTP servers for each email
37
+ # domain are memoized to improve performance.
38
+ def smtp_servers_for_addr(addr)
39
+ if (addr =~ /<(.*)@(.*)>/) or (addr =~ /(.*)@(.*)/)
40
+ domain = $2
41
+ else
42
+ raise RuntimeError, "Invalid email address."
43
+ end
44
+ @mutex.synchronize do
45
+ @smtp_servers[domain] ||= BulkMail.smtp_servers_for_domain(domain)
46
+ end
47
+ end
48
+
49
+ # Sends an email to a recipient by trying any of the SMTP servers available
50
+ # for the recipient.
51
+ def send(addr)
52
+ servers = smtp_servers_for_addr(addr)
53
+ if servers.empty?
54
+ report_addr(addr, :error, 'Unknown domain')
55
+ return
56
+ end
57
+ servers.each do |server|
58
+ begin
59
+ send_message(server, addr)
60
+ report_addr(addr, :success)
61
+ return
62
+ rescue => e
63
+ report_addr(addr, :error, e.message)
64
+ end
65
+ end
66
+ end
67
+
68
+ # Opens a connection to an SMTP server and sends the message.
69
+ def send_message(server, addr)
70
+ Net::SMTP.start(
71
+ server, nil, @opts[:helo], nil, nil, nil) do |smtp|
72
+ smtp.send_message @opts[:message].to_s, @opts[:from], addr
73
+ end
74
+ end
75
+
76
+ # Reports success or failure for the recipient.
77
+ def report_addr(addr, status, error = nil)
78
+ @mutex.synchronize do
79
+ case status
80
+ when :success:
81
+ puts "Sent mail to #{addr}"
82
+ File.open('sent.bulkmail', 'a') {|f| f.puts "#{addr}"}
83
+ when :error:
84
+ puts "Failed to send to #{addr}: #{error}"
85
+ File.open('failed.bulkmail', 'a') {|f| f.puts "#{addr}: #{error}"}
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,113 @@
1
+ require 'thread'
2
+
3
+ # This class implements a pool of threads. The pool hands out blocks to be
4
+ # processed to any available worker thread. If no worker thread is available,
5
+ # the pool blocks until any of the workers have become available. The pool
6
+ # size can be specified when constructing the pool. For exmaple:
7
+ #
8
+ # pool = ThreadPool.new(10)
9
+ # 10.times {pool.process {sleep 3}}
10
+ # 10.times {pool.process {sleep 3}} # this will block for about 3 seconds.
11
+ # pool.join # this will block for about 3 more seconds.
12
+ class ThreadPool
13
+ # The Worker class starts a thread for performing work. A worker thread
14
+ # sleeps until it receives a block for processing, at which time it is
15
+ # considered busy. Once the block has been processed, the worker is
16
+ # considered available again.
17
+ class Worker
18
+ # Starts the worker thread.
19
+ def initialize
20
+ @mutex = Mutex.new
21
+ @thread = Thread.new do
22
+ while true
23
+ sleep 0.001
24
+ block = get_block
25
+ if block
26
+ block.call
27
+ reset_block
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ # Returns the block to be processed, if any.
34
+ def get_block
35
+ @mutex.synchronize {@block}
36
+ end
37
+
38
+ # Sets the block to be processed.
39
+ def set_block(block)
40
+ @mutex.synchronize {@block = block}
41
+ end
42
+
43
+ # Resets the block instance variable to signify that the worker
44
+ # is available.
45
+ def reset_block
46
+ @mutex.synchronize {@block = nil}
47
+ end
48
+
49
+ # Returns true if the worker is busy processing a block.
50
+ def busy?
51
+ @mutex.synchronize {!@block.nil?}
52
+ end
53
+ end
54
+
55
+ attr_accessor :max_size
56
+ attr_reader :workers
57
+
58
+ # Creates a new pool with a default maximum size of 10.
59
+ def initialize(max_size = 10)
60
+ @max_size = max_size
61
+ @workers = []
62
+ @mutex = Mutex.new
63
+ end
64
+
65
+ # Returns the number of workers in the pool.
66
+ def size
67
+ @mutex.synchronize {@workers.size}
68
+ end
69
+
70
+ # Returns true if any of the workers in the pool are busy.
71
+ def busy?
72
+ @mutex.synchronize {@workers.any? {|w| w.busy?}}
73
+ end
74
+
75
+ # Blocks until all workers have become available.
76
+ def join
77
+ sleep 0.01 while busy?
78
+ end
79
+
80
+ # Hands the block to an available worker. If no workers are available, this
81
+ # method will block until one becomes available.
82
+ def process(&block)
83
+ wait_for_worker.set_block(block)
84
+ end
85
+
86
+ # Waits for a worker to become available.
87
+ def wait_for_worker
88
+ while true
89
+ worker = find_available_worker
90
+ return worker if worker
91
+ sleep 0.01
92
+ end
93
+ end
94
+
95
+ # Returns an available worker or a new worker if the maximum pool size
96
+ # has not been reached.
97
+ def find_available_worker
98
+ @mutex.synchronize {free_worker || create_worker}
99
+ end
100
+
101
+ # Returns the first available worker.
102
+ def free_worker
103
+ @workers.each {|w| return w unless w.busy?}; nil
104
+ end
105
+
106
+ # Creates a worker if the maximum pool size has not been reached.
107
+ def create_worker
108
+ return nil if @workers.size >= @max_size
109
+ worker = Worker.new
110
+ @workers << worker
111
+ worker
112
+ end
113
+ end
data/lib/bulkmail.rb CHANGED
@@ -1,75 +1,4 @@
1
- require 'action_mailer'
2
-
3
- # The BulkMail module contains the Job class, which does the actual
4
- # bulk-mailing, and a convenience method to start a job.
5
- module BulkMail
6
- class Mailer < ActionMailer::Base #:nodoc
7
- def msg(addr, sub, content, from_addr, c_type, additional_headers)
8
- recipients addr
9
- subject sub if sub
10
- body content
11
- from from_addr if from_addr
12
- content_type c_type if c_type
13
- headers additional_headers if additional_headers
14
- end
15
- end
16
-
17
- # The Job class performs the bulk-mailing work. Each instance starts a thread
18
- # That goes over the list of recipients and sends each one an email. A job
19
- # can also be queried for its progress, returning a number between 0 and 1.
20
- class Job
21
- attr_accessor :progress, :errors, :thread, :delivered, :total
22
-
23
- def initialize(opts)
24
- @recipients = IO.readlines(opts[:recipients_file])
25
- @subject = opts[:subject]
26
- @from = opts[:from]
27
- @content_type = opts[:content_type]
28
- @content = load_content(opts[:content_file])
29
- @progress = 0
30
- @delivered = 0
31
- @total = @recipients.size
32
- @errors = []
33
- @thread = Thread.new {process}
34
- end
35
-
36
- def load_content(fn)
37
- content = IO.readlines(fn)
38
- while line = content.shift
39
- break if line.empty?
40
- if line =~ /^(.+):\s?(.*)$/
41
- @headers ||= {}
42
- @headers[$1] = $2
43
- else
44
- content.unshift(line)
45
- break
46
- end
47
- end
48
- content.join
49
- end
50
-
51
- def process
52
- @recipients.each_with_index do |addr, idx|
53
- begin
54
- Mailer.deliver_msg(addr, @subject, @content, @from, @content_type, @headers)
55
- @progress = idx / @recipients.size
56
- @delivered += 1
57
- puts "Sent email to #{addr}"
58
- rescue => e
59
- puts e.message
60
- @errors << e
61
- end
62
- end
63
- @progress = 1
64
- end
65
- end
66
-
67
- def self.send(opts)
68
- Job.new(opts)
69
- end
70
-
71
- def self.server_settings
72
- ActionMailer::Base.server_settings
73
- end
74
- end
75
-
1
+ require File.join(File.dirname(__FILE__), 'bulkmail/thread_pool')
2
+ require File.join(File.dirname(__FILE__), 'bulkmail/dns_mx')
3
+ require File.join(File.dirname(__FILE__), 'bulkmail/message')
4
+ require File.join(File.dirname(__FILE__), 'bulkmail/sender')
metadata CHANGED
@@ -3,8 +3,8 @@ rubygems_version: 0.9.0
3
3
  specification_version: 1
4
4
  name: bulkmail
5
5
  version: !ruby/object:Gem::Version
6
- version: "0.1"
7
- date: 2006-10-18 00:00:00 +02:00
6
+ version: 0.3.0
7
+ date: 2007-01-12 00:00:00 +02:00
8
8
  summary: Ruby bulk-mailer.
9
9
  require_paths:
10
10
  - lib
@@ -33,7 +33,14 @@ files:
33
33
  - README
34
34
  - Rakefile
35
35
  - bin/bulkmail
36
+ - doc/rdoc
37
+ - doc/rdoc/created.rid
38
+ - lib/bulkmail
36
39
  - lib/bulkmail.rb
40
+ - lib/bulkmail/dns_mx.rb
41
+ - lib/bulkmail/message.rb
42
+ - lib/bulkmail/sender.rb
43
+ - lib/bulkmail/thread_pool.rb
37
44
  - CHANGELOG
38
45
  test_files: []
39
46
 
@@ -63,7 +70,7 @@ requirements: []
63
70
 
64
71
  dependencies:
65
72
  - !ruby/object:Gem::Dependency
66
- name: actionmailer
73
+ name: pNet-DNS
67
74
  version_requirement:
68
75
  version_requirements: !ruby/object:Gem::Version::Requirement
69
76
  requirements: