├── 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 | [![Build Status](https://travis-ci.org/ujifgc/system_mail.png)](https://travis-ci.org/ujifgc/system_mail) 2 | [![Code Climate](https://codeclimate.com/github/ujifgc/system_mail.png)](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 | --------------------------------------------------------------------------------