├── Gemfile
├── .travis.yml
├── test
├── system_mail
│ ├── system_mail_test.rb
│ └── system_mail-storage_test.rb
├── fixtures
│ └── test.html
└── minitest_helper.rb
├── lib
├── system_mail
│ ├── version.rb
│ ├── storage.rb
│ └── message.rb
└── system_mail.rb
├── CHANGES.md
├── Rakefile
├── .gitignore
├── system_mail.gemspec
├── LICENSE.txt
└── README.md
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gemspec
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: ruby
2 | rvm:
3 | - 1.9.3
4 | - 2.0.0
5 |
--------------------------------------------------------------------------------
/test/system_mail/system_mail_test.rb:
--------------------------------------------------------------------------------
1 | require 'minitest_helper'
2 |
--------------------------------------------------------------------------------
/test/fixtures/test.html:
--------------------------------------------------------------------------------
1 | small big нормал
2 |
--------------------------------------------------------------------------------
/lib/system_mail/version.rb:
--------------------------------------------------------------------------------
1 | module SystemMail
2 | VERSION = "0.0.5"
3 | end
4 |
--------------------------------------------------------------------------------
/test/minitest_helper.rb:
--------------------------------------------------------------------------------
1 | require 'minitest/autorun'
2 | require 'system_mail'
3 |
--------------------------------------------------------------------------------
/CHANGES.md:
--------------------------------------------------------------------------------
1 | #### 0.0.4
2 |
3 | - Allow UTF-8 characters in From: and To: fields
4 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 | require 'rake/testtask'
3 |
4 | task :default => :test
5 |
6 | Rake::TestTask.new :test do |t|
7 | t.libs << "test"
8 | t.test_files = FileList['test/**/*_test.rb']
9 | end
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.gem
2 | *.rbc
3 | .bundle
4 | .config
5 | .yardoc
6 | Gemfile.lock
7 | InstalledFiles
8 | _yardoc
9 | coverage
10 | doc/
11 | lib/bundler/man
12 | pkg
13 | rdoc
14 | spec/reports
15 | test/tmp
16 | test/version_tmp
17 | tmp
18 |
--------------------------------------------------------------------------------
/lib/system_mail.rb:
--------------------------------------------------------------------------------
1 | require 'system_mail/version'
2 | require 'system_mail/message'
3 |
4 | module SystemMail
5 | def self.new(options = {}, &block)
6 | message = Message.new(options)
7 | message.instance_eval(&block) if block_given?
8 | message
9 | end
10 |
11 | def self.email(options = {}, &block)
12 | message = SystemMail.new(options, &block)
13 | message.deliver
14 | end
15 | end
16 |
17 | Mail = SystemMail
18 |
--------------------------------------------------------------------------------
/system_mail.gemspec:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | lib = File.expand_path('../lib', __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require 'system_mail/version'
5 |
6 | Gem::Specification.new do |spec|
7 | spec.name = "system_mail"
8 | spec.version = SystemMail::VERSION
9 | spec.authors = ["Igor Bochkariov"]
10 | spec.email = ["ujifgc@gmail.com"]
11 | spec.description = 'A Ruby library built to compose and deliver internet mail using operating system utilities.'
12 | spec.summary = 'SystemMail is a blazing-fast Ruby Mail alternative with tiny memory footprint.'
13 | spec.homepage = "https://github.com/ujifgc/system_mail"
14 | spec.license = "MIT"
15 |
16 | spec.files = `git ls-files`.split($/)
17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19 | spec.require_paths = ["lib"]
20 |
21 | spec.add_development_dependency "bundler", "~> 1.3"
22 | spec.add_development_dependency "rake"
23 | end
24 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright (c) 2013 Igor Bochkariov
2 |
3 | MIT License
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining
6 | a copy of this software and associated documentation files (the
7 | "Software"), to deal in the Software without restriction, including
8 | without limitation the rights to use, copy, modify, merge, publish,
9 | distribute, sublicense, and/or sell copies of the Software, and to
10 | permit persons to whom the Software is furnished to do so, subject to
11 | the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be
14 | included in all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23 |
--------------------------------------------------------------------------------
/lib/system_mail/storage.rb:
--------------------------------------------------------------------------------
1 | require 'tempfile'
2 | require 'fileutils'
3 |
4 | module SystemMail
5 | ##
6 | # A class to store string data either in StringIO or in Tempfile.
7 | #
8 | class Storage
9 | def initialize(path = nil)
10 | @tmpdir = path || Dir.tmpdir
11 | @io = StringIO.new
12 | end
13 |
14 | def puts(data = nil)
15 | @io.puts(data)
16 | end
17 |
18 | def read
19 | if file?
20 | capture do |file_path|
21 | File.read file_path
22 | end
23 | else
24 | @io.string.dup
25 | end
26 | end
27 |
28 | def capture
29 | ensure_tempfile
30 | @io.close
31 | yield @io.path
32 | @io.open
33 | end
34 |
35 | def file?
36 | @io.kind_of?(Tempfile)
37 | end
38 |
39 | def clear
40 | @io.close
41 | @io.unlink if file?
42 | end
43 |
44 | private
45 |
46 | def ensure_tempfile
47 | return if file?
48 | tempfile = create_tempfile
49 | tempfile.puts @io.string if @io.size > 0
50 | @io = tempfile
51 | end
52 |
53 | def create_tempfile
54 | temp_directory = File.join(@tmpdir, 'system_mail')
55 | FileUtils.mkdir_p(temp_directory)
56 | Tempfile.new('storage', temp_directory, :mode => IO::APPEND)
57 | end
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/test/system_mail/system_mail-storage_test.rb:
--------------------------------------------------------------------------------
1 | #coding:utf-8
2 | require 'minitest_helper'
3 |
4 | describe SystemMail::Storage do
5 | before do
6 | SystemMail::Storage.class_eval{ attr_accessor :io }
7 | @fixture_path = File.expand_path('../fixtures/test.html', File.dirname(__FILE__))
8 | @fixture = File.read @fixture_path
9 | @s = SystemMail::Storage.new
10 | end
11 |
12 | after do
13 | @s.clear
14 | end
15 |
16 | it 'should properly initialize' do
17 | assert_kind_of StringIO, @s.io
18 | end
19 |
20 | it 'should properly write strings' do
21 | 2.times do
22 | @s.puts 'line1'
23 | @s.puts 'а также линия'
24 | @s.puts
25 | end
26 | assert_equal "line1\nа также линия\n\n"*2, @s.io.string
27 | assert_kind_of StringIO, @s.io
28 | end
29 |
30 | it 'should properly write and append files with commandline' do
31 | 2.times do
32 | @s.capture do |path|
33 | `cat '#{@fixture_path}' >> '#{path}'`
34 | end
35 | end
36 | assert_equal @fixture*2, File.read(@s.io.path)
37 | assert_kind_of Tempfile, @s.io
38 | assert_match /system_mail(.*)storage/, @s.io.path
39 | end
40 |
41 | it 'should properly mix strings and files' do
42 | 2.times do
43 | @s.puts 'line1'
44 | @s.puts 'а также линия'
45 | @s.puts
46 | @s.capture do |path|
47 | File.open(path, 'a') { |f| f.write @fixture }
48 | end
49 | end
50 | assert_equal ("line1\nа также линия\n\n"+@fixture)*2, File.read(@s.io.path)
51 | assert_kind_of Tempfile, @s.io
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.org/ujifgc/system_mail)
2 | [](https://codeclimate.com/github/ujifgc/system_mail)
3 |
4 | ## Introduction
5 |
6 | SystemMail is a Ruby library built to compose and deliver internet mail using
7 | operating system utilities.
8 |
9 | SystemMail features:
10 |
11 | * tiny memory footprint even with big attachments
12 | * blazing-fast gem loading and message composing
13 | * alternating message body format: text, enriched, HTML
14 | * rich capabilities in attaching files
15 | * ability to combine HTML message with file attachments
16 |
17 | Operating system commands used to do the job are:
18 |
19 | * `sendmail -t < temp` or alternative sends the message to Mail Transfer Agent
20 | * `base64 file >> temp` encodes binary files to textual form
21 | * `file --mime-type --mime-encoding -b file` detects Content-Type and charset
22 |
23 | ## Installation
24 |
25 | Add this line to your application's Gemfile:
26 |
27 | gem 'system_mail'
28 |
29 | And then execute:
30 |
31 | $ bundle
32 |
33 | Or install it yourself as:
34 |
35 | $ gem install system_mail
36 |
37 | ## Usage
38 |
39 | mail = SystemMail.new(
40 | from: 'user@example.com',
41 | to: ['user1@gmail.com', 'user2@gmail.com'],
42 | subject: 'test проверочный subject',
43 | attachments: ['Gemfile', 'Gemfile.lock'],
44 | text: 'big small норм',
45 | html: File.read('test.html')
46 | )
47 | mail.deliver
48 |
49 | ## Contributing
50 |
51 | 1. Fork it
52 | 2. Create your feature branch (`git checkout -b my-new-feature`)
53 | 3. Commit your changes (`git commit -am 'Add some feature'`)
54 | 4. Push to the branch (`git push origin my-new-feature`)
55 | 5. Create new Pull Request
56 |
--------------------------------------------------------------------------------
/lib/system_mail/message.rb:
--------------------------------------------------------------------------------
1 | require 'base64'
2 | require 'shellwords'
3 | require 'system_mail/storage'
4 |
5 | module SystemMail
6 | ##
7 | # Manages compiling an email message from various attributes and files.
8 | #
9 | class Message
10 | BASE64_SIZE = 76
11 | UTF8_SIZE = 998
12 | SETTINGS = {
13 | :sendmail => '/usr/sbin/sendmail -t',
14 | :base64 => 'base64',
15 | :file => 'file --mime-type --mime-encoding -b',
16 | :storage => ENV['TMP'] || '/tmp',
17 | }.freeze
18 |
19 | ##
20 | # Creates new message. Available options:
21 | #
22 | # - :text, String, Textual part of the message
23 | # - :enriched, String, Enriched alternative of the message (RFC 1896)
24 | # - :html, String, HTML alternative of the message
25 | # - :from, String, 'From:' header for the message
26 | # - :to, String or Array of Strings, 'To:' header, if Arrey, it gets joined by ', '
27 | # - :subject, String, Subject of the message, it gets encoded automatically
28 | # - :files, File or String of file path or Array of them, Attachments of the message
29 | #
30 | # Options :text, :enriched and :html are interchangeable.
31 | # Option :to is required.
32 | #
33 | # Examples:
34 | #
35 | # mail = Message.new(
36 | # :from => 'user@example.com',
37 | # :to => 'user@gmail.com',
38 | # :subject => 'test subject',
39 | # :text => 'big small normal',
40 | # :html => File.read('test.html'),
41 | # :attachments => [File.open('Gemfile'), 'attachment.zip'],
42 | # )
43 | #
44 | def initialize(options={})
45 | @body = {}
46 | @to = []
47 | @mutex = Mutex.new
48 | %W(text enriched html from to subject attachments).each do |option|
49 | name = option.to_sym
50 | send(name, options[name])
51 | end
52 | end
53 |
54 | ##
55 | # Delivers the message using sendmail.
56 | #
57 | # Example:
58 | #
59 | # mail.deliver #=> nil
60 | #
61 | def deliver
62 | validate
63 | with_storage do
64 | write_headers
65 | write_message
66 | send_message
67 | end
68 | end
69 |
70 | def settings
71 | @settings ||= SETTINGS.dup
72 | end
73 |
74 | private
75 |
76 | def text(input)
77 | @body['text'] = input
78 | end
79 |
80 | def enriched(input)
81 | @body['enriched'] = input
82 | end
83 |
84 | def html(input)
85 | @body['html'] = input
86 | end
87 |
88 | def from(input)
89 | @from = input
90 | end
91 |
92 | def to(input)
93 | @to += Array(input)
94 | end
95 |
96 | def subject(input)
97 | @subject = input
98 | end
99 |
100 | def attachments(input)
101 | input && input.each{ |file| add_file(*file) }
102 | end
103 |
104 | def add_file(name, path = nil)
105 | path ||= name
106 | name = File.basename(name)
107 | files[name] = path
108 | end
109 |
110 | def files
111 | @files ||= {}
112 | end
113 |
114 | def with_storage
115 | @mutex.synchronize do
116 | @storage = Storage.new settings[:storage]
117 | yield
118 | @storage.clear
119 | @storage = nil
120 | end
121 | end
122 |
123 | def write_message
124 | if files.any?
125 | multipart :mixed do |boundary|
126 | write_part boundary
127 | write_body
128 | files.each_pair do |name, path|
129 | write_part boundary
130 | write_file name, path
131 | end
132 | end
133 | else
134 | write_body
135 | end
136 | end
137 |
138 | def write_body
139 | case @body.length
140 | when 0
141 | nil
142 | when 1
143 | data, type = @body.first
144 | write_content data, "text/#{type}"
145 | else
146 | multipart :alternative do |boundary|
147 | %w[text enriched html].each do |type|
148 | data = @body[type] || next
149 | write_part boundary
150 | write_content data, "text/#{type}"
151 | end
152 | end
153 | end
154 | end
155 |
156 | def write_content(data, content_type)
157 | if data.bytesize < UTF8_SIZE || !data.lines.any?{ |line| line.bytesize > UTF8_SIZE }
158 | write_8bit_data data, content_type
159 | else
160 | write_base64_data data, content_type
161 | end
162 | end
163 |
164 | def multipart(type)
165 | boundary = new_boundary(type)
166 | @storage.puts "Content-Type: multipart/#{type}; boundary=\"#{boundary}\""
167 | yield boundary
168 | write_part boundary, :end
169 | end
170 |
171 | def write_headers
172 | @storage.puts "From: #{encode_from}" if @from
173 | @storage.puts "To: #{encode_to}"
174 | @storage.puts "Subject: #{encode_subject}"
175 | end
176 |
177 | def write_part(boundary, type = :begin)
178 | @storage.puts
179 | @storage.puts "--#{boundary}#{type == :end ? '--' : ''}"
180 | end
181 |
182 | def write_base64_data(data, content_type)
183 | @storage.puts "Content-Type: #{content_type}; charset=#{data.encoding}"
184 | @storage.puts "Content-Transfer-Encoding: base64"
185 | @storage.puts
186 | Base64.strict_encode64(data).scan(/.{1,#{BASE64_SIZE}/).each do |line|
187 | @storage.puts line
188 | end
189 | end
190 |
191 | def write_8bit_data(data, content_type)
192 | @storage.puts "Content-Type: #{content_type}; charset=#{data.encoding}"
193 | @storage.puts "Content-Transfer-Encoding: 8bit"
194 | @storage.puts
195 | @storage.puts data
196 | end
197 |
198 | def write_file(name, file)
199 | path = case file
200 | when String
201 | fail Errno::ENOENT, file unless File.file?(file)
202 | fail Errno::EACCES, file unless File.readable?(file)
203 | file
204 | when File
205 | file.path
206 | else
207 | fail ArgumentError, 'attachment must be File or String of file path'
208 | end
209 | write_base64_file name, path
210 | end
211 |
212 | def write_base64_file(name, path)
213 | @storage.puts "Content-Type: #{read_mime(path)}"
214 | @storage.puts "Content-Transfer-Encoding: base64"
215 | @storage.puts "Content-Disposition: attachment; filename=\"#{name}\""
216 | @storage.puts
217 | @storage.capture do |message_path|
218 | `#{settings[:base64]} '#{path.shellescape}' >> #{message_path}`
219 | end
220 | end
221 |
222 | def send_message
223 | if @storage.file?
224 | @storage.capture do |message_path|
225 | `#{settings[:sendmail]} < #{message_path}`
226 | end
227 | else
228 | IO.popen(settings[:sendmail], 'w') do |io|
229 | io.puts @storage.read
230 | end
231 | end
232 | end
233 |
234 | def read_mime(file_path)
235 | `#{settings[:file]} '#{file_path.shellescape}'`.strip
236 | end
237 |
238 | def validate
239 | fail ArgumentError, "Header 'To:' is empty" if @to.empty?
240 | warn 'Message body is empty' if @body.empty?
241 | end
242 |
243 | def new_boundary(type)
244 | rand(36**6).to_s(36).rjust(20,type.to_s)
245 | end
246 |
247 | def encode_subject
248 | @subject.ascii_only? ? @subject : encode_utf8(@subject)
249 | end
250 |
251 | def encode_from
252 | encode_address(@from)
253 | end
254 |
255 | def encode_to
256 | @to.map do |to|
257 | encode_address(to)
258 | end.join(', ')
259 | end
260 |
261 | def encode_address(address)
262 | if address.ascii_only?
263 | address
264 | else
265 | if matchdata = address.match(/(.+)\s\<(.+)\>/)
266 | "#{encode_utf8 matchdata[1]} <#{matchdata[2]}>"
267 | else
268 | address
269 | end
270 | end
271 | end
272 |
273 | def encode_utf8(input)
274 | "=?UTF-8?B?#{Base64.strict_encode64 input}?="
275 | end
276 | end
277 | end
278 |
--------------------------------------------------------------------------------