diff --git a/.document b/.document
new file mode 100644
index 0000000..7f80cfc
--- /dev/null
+++ b/.document
@@ -0,0 +1,3 @@
+-
+ChangeLog.md
+LICENSE.txt
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..055f6b7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+Gemfile.lock
+doc/
+pkg/
+vendor/cache/*.gem
+*.eml
diff --git a/.rspec b/.rspec
new file mode 100644
index 0000000..660778b
--- /dev/null
+++ b/.rspec
@@ -0,0 +1 @@
+--colour --format documentation
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..77fee73
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+1.9.3
diff --git a/.yardopts b/.yardopts
new file mode 100644
index 0000000..694107c
--- /dev/null
+++ b/.yardopts
@@ -0,0 +1 @@
+--markup markdown --title "TackleBox Documentation" --protected
diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
index 0000000..a222030
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,4 @@
+### 0.1.0 / 2014-06-03
+
+* Initial release:
+
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..952588e
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,11 @@
+source 'https://rubygems.org/'
+
+gemspec
+
+group :development do
+ gem 'rake'
+ gem 'rspec', '~> 3.0'
+
+ gem 'kramdown'
+ gem 'yard', '~> 0.8'
+end
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..801e02e
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1 @@
+Copyright (c) 2014 Trail of Bits, Inc.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ac437e3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,141 @@
+# tacklebox
+
+* [Homepage](https://github.com/trailofbits/tackle_box)
+* [Documentation](http://rubydoc.info/gems/tackle_box/frames)
+
+## Description
+
+A phishing toolkit for generating and sending phishing emails.
+
+## Features
+
+* Provides a [Liquid][liquid] + Markdown email template system.
+* Provides built-in [liquid] tags for customized emails:
+ * {TackleBox::Email::Template::Tags::Attachment attachment}
+ * {TackleBox::Email::Template::Tags::Date date}
+ * {TackleBox::Email::Template::Tags::Day day}
+ * {TackleBox::Email::Template::Tags::Link link}
+ * {TackleBox::Email::Template::Tags::RandomDate random_date}
+ * {TackleBox::Email::Template::Tags::RandomDay random_day}
+ * {TackleBox::Email::Template::Tags::RandomGreeting random_greeting}
+ * {TackleBox::Email::Template::Tags::RandomWeekDay random_week_day}
+ * {TackleBox::Email::Template::Tags::WeekDay week_day}
+ * {TackleBox::Email::Template::Tags::Year year}
+* Renders dynamic emails based on the recipient.
+* Supports targeting specific sub-groups of people within an organization.
+* Supports embedding links and/or attachments into emails.
+* Supports custom 1x1 tracking pixel URLs.
+
+## API
+
+* {TackleBox::Attachment}
+* {TackleBox::Link}
+* {TackleBox::Person}
+* {TackleBox::Organization}
+* {TackleBox::Campaign}
+ * {TackleBox::Campaign#initialize}
+ * {TackleBox::Campaign#each_email}
+
+## Examples
+
+ require 'tackle_box'
+
+ email_template = TackleBox::Email::Template.open('nude_pics.eml')
+
+ org = TackleBox::Organization.new(
+ 'Trail of Bits, Inc.',
+
+ domain: 'trailofbits.com',
+ website: 'http://www.trailofbits.com/',
+ industry: 'Technology'
+ )
+ org.person('dan@trailofbits.com', name: 'Dan Guido',
+ title: 'CEO',
+ department: 'Management',
+ location: 'New York, NY')
+
+ campaign = TackleBox::Campaign.new(
+ email_template, org,
+
+ link: 'http://www.imdurr.com/gallery/x5ae18.png',
+ tracking_pixel: 'http://www.imdurr.com/pixel.gif',
+ account: TackleBox::Person.new(
+ 'nick.d@yelpfortoilets.com',
+ name: 'Nick Depetrillo',
+ title: 'CTO'
+ )
+ )
+
+ sender = TackleBox::Email::Sender.new(
+ 'trailofberts.com' => {
+ address: 'mail.yelpfortoilets.com',
+ port: 587,
+ user_name: 'nick.d',
+ password: 'test1234',
+ authentication: :plain,
+ enable_starttls_auto: true
+ }
+ )
+
+ campaign.each_email do |email|
+ puts ">>> Sending email to #{email.to}"
+ puts email
+
+ sender.send(email)
+ end
+
+### Example Email Template
+
+ Date: Tue, 01 Jul 2014 14:21:08 -0700
+ Message-ID: <53b32644bd0dd_b221112ff0516b4@tank.lab.mail>
+ Subject: Nude pics!
+ Mime-Version: 1.0
+ Content-Type: text/html;
+ charset=ISO-8895-1
+ Content-Transfer-Encoding: 7bit
+
+
+
+
+ test email
+
+
+ Yo {{ to.name }}, checkout these nudes
+ of Brian Krebs!
+
+
+
+## Requirements
+
+* [Ruby] >= 1.9.3
+* [activesupport] ~> 3.2
+* [shorturl] ~> 1.0
+* [liquid] ~> 2.4
+* [nokogiri] ~> 1.6
+* [chars] ~> 0.2
+* [mail] ~> 2.5
+
+## Install
+
+ $ bundle install
+
+## Testing
+
+ $ rake spec
+
+## Documentation
+
+ $ rake yard
+ $ open ./doc/index.html
+
+## Copyright
+
+Copyright (c) 2014 Trail of Bits, Inc.
+
+[Ruby]: http://www.ruby-lang.org/
+[activesupport]: https://github.com/rails/rails/tree/master/activesupport#readme
+[shorturl]: https://github.com/robbyrussell/shorturl#readme
+[liquid]: http://liquidmarkup.org/
+[nokogiri]: http://nokogiri.org/
+[chars]: https://github.com/postmodern/chars#readme
+[mail]: https://github.com/mikel/mail
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..3dc3a91
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,32 @@
+# encoding: utf-8
+
+require 'rubygems'
+
+begin
+ require 'bundler'
+rescue LoadError => e
+ warn e.message
+ warn "Run `gem install bundler` to install Bundler."
+ exit -1
+end
+
+begin
+ Bundler.setup(:development)
+rescue Bundler::BundlerError => e
+ warn e.message
+ warn "Run `bundle install` to install missing gems."
+ exit e.status_code
+end
+
+require 'rake'
+require 'bundler/gem_tasks'
+
+require 'rspec/core/rake_task'
+RSpec::Core::RakeTask.new
+
+task :test => :spec
+task :default => :spec
+
+require 'yard'
+YARD::Rake::YardocTask.new
+task :doc => :yard
diff --git a/bin/edit b/bin/edit
new file mode 100755
index 0000000..de199e6
--- /dev/null
+++ b/bin/edit
@@ -0,0 +1,74 @@
+#!/usr/bin/env ruby
+
+require 'bundler/setup'
+require 'tackle_box/version'
+require 'tackle_box/email/editor'
+
+require 'optparse'
+
+replacements = []
+domains = []
+
+from = nil
+from_name = nil
+dry_run = false
+
+options = OptionParser.new("usage: #{$0} [OPTIONS] EMAIL") do |opts|
+ opts.on('-R','--replace STRING:SUB','Replaces STRING with SUB') do |arg|
+ replacements << arg.split(':',2)
+ end
+
+ opts.on('-f','--from [ADDR]','Replaces the From address with ADDR') do |addr|
+ from = addr || '{{ from.address }}'
+ end
+
+ opts.on('-F','--from-name [NAME]','Replaces the From name') do |name|
+ from_name = name || '{{ from.name }}'
+ end
+
+ opts.on('-D','--domain OLD:NEW','Replaces the OLD domain with NEW') do |arg|
+ domains << arg.split(':',2)
+ end
+
+ opts.on('-d','--dry-run','Do not save the edits') do
+ dry_run = true
+ end
+
+ opts.on('-V','--version','Prints the tacklebox version') do
+ puts TackleBox::VERSION
+ exit
+ end
+
+ opts.on('-h','--help','Prints this message') do
+ puts opts
+ exit
+ end
+end
+
+options.parse!(ARGV)
+
+unless (path = ARGV.first)
+ abort "#{$0}: EMAIL argument not specified"
+end
+
+editor = TackleBox::Email::Editor.new(path)
+
+# always dequote the email
+editor.dequote
+
+editor.replace_to
+editor.replace_to_name
+editor.replace_from(from) if from
+editor.replace_from_name(from_name) if from_name
+
+domains.each do |(old,new)|
+ editor.replace_domain(old,new)
+end
+
+replacements.each do |(str,sub)|
+ editor.replace(str,sub)
+end
+
+unless dry_run then editor.save
+else puts editor
+end
diff --git a/lib/tackle_box.rb b/lib/tackle_box.rb
new file mode 100644
index 0000000..6e9da3b
--- /dev/null
+++ b/lib/tackle_box.rb
@@ -0,0 +1,6 @@
+require 'tackle_box/person'
+require 'tackle_box/organization'
+require 'tackle_box/attachment'
+require 'tackle_box/link'
+require 'tackle_box/campaign'
+require 'tackle_box/version'
diff --git a/lib/tackle_box/attachment.rb b/lib/tackle_box/attachment.rb
new file mode 100644
index 0000000..1c772c1
--- /dev/null
+++ b/lib/tackle_box/attachment.rb
@@ -0,0 +1,96 @@
+module TackleBox
+ class Attachment
+
+ # The path to the file to attach
+ attr_reader :path
+
+ # The desired name of the attachment
+ attr_reader :name
+
+ # The contents for the attachment
+ attr_reader :content
+
+ # The encoding of the attachment
+ attr_reader :encoding
+
+ # The password for the attachment
+ attr_reader :password
+
+ #
+ # Initializes the attachment.
+ #
+ # @param [Hash] options
+ # Additional options.
+ #
+ # @option options [String] :name
+ # The desired name for the attachment.
+ # Defaults to the basename of the path.
+ #
+ # @option options [String] :content
+ # The optional contents for the attachment.
+ # Defaults to the contents of the file.
+ #
+ # @option options [String] :encoding
+ # The encoding of the attachment (ex: `zip`, `7z`, `rar`).
+ # Defaults to the extension of the attachment name.
+ #
+ # @option options [String] :password
+ # The password for the attachment.
+ #
+ # @raise [ArgumentError]
+ # Either `:path` or `:content` must be specified, or `:name`
+ #
+ # @example Use an existing file:
+ # Attachment.new(path: 'path/to/nudes.exe')
+ #
+ # @example Use an existing file but with a different name:
+ # Attachment.new(path: 'path/to/nudes.exe', name: 'nudes.jpg')
+ #
+ # @example Specify a custom name and contents:
+ # Attachment.new(name: 'nudes.jpg', content: data)
+ #
+ def initialize(options={})
+ unless (options[:path] || options[:content])
+ raise(ArgumentError,"must specify :path or :content")
+ end
+
+ @path = options[:path]
+ @name = options.fetch(:name) do
+ unless @path
+ raise(ArgumentError,":path or :name are required")
+ end
+
+ File.basename(@path)
+ end
+
+ @content = options.fetch(:content) do
+ File.binread(@path)
+ end
+
+ @encoding = options.fetch(:encoding) do
+ File.extname(@name)[1..-1]
+ end
+
+ @password = options[:password]
+ end
+
+ #
+ # Converts the attachment into a liquid hash.
+ #
+ # @return [Hash{String => String,nil}]
+ # The liquid hash containing:
+ #
+ # * `name`
+ # * `encoding`
+ # * `password`
+ #
+ def to_liquid
+ {
+ 'name' => @name,
+ 'encoding' => @encoding,
+ 'password' => @password
+ }
+ end
+
+ end
+end
diff --git a/lib/tackle_box/campaign.rb b/lib/tackle_box/campaign.rb
new file mode 100644
index 0000000..44f4748
--- /dev/null
+++ b/lib/tackle_box/campaign.rb
@@ -0,0 +1,240 @@
+require 'tackle_box/organization'
+require 'tackle_box/email/template'
+require 'tackle_box/link'
+require 'tackle_box/attachment'
+
+require 'chars'
+require 'mail'
+
+module TackleBox
+ class Campaign
+
+ # Headers for common email mailers
+ HEADERS = {
+ 'outlook' => {'X-Mailer' => 'Microsoft Office Outlook, Build 12.0.4210'},
+ 'apple_mail' => {'X-Mailer' => 'Apple Mail (2.1498)'},
+ 'thunderbird' => {'User-Agent' => 'Mozilla/5.0 (X11; Linux x86_64; rv:15.0) Gecko/20120911 Thunderbird/15.0.1'}
+ }
+
+ # Default domain to use for additional resources
+ DEFAULT_DOMAIN = 'apt.trailofbits.com'
+
+ # The organization that's being targetted.
+ #
+ # @return [Organization]
+ attr_reader :organization
+
+ # The email template to use.
+ #
+ # @return [Email::Template]
+ attr_reader :email_template
+
+ # The link to send.
+ #
+ # @return [Link]
+ attr_reader :link
+
+ # The attachment to send.
+ #
+ # @return [Attachment]
+ attr_reader :attachment
+
+ # The accounts to send emails from.
+ #
+ # @return [Array]
+ attr_reader :accounts
+
+ # Intended recipients of the campaign.
+ #
+ # @return [Array]
+ attr_reader :recipients
+
+ # Mapping of identification tokens back to recipients.
+ #
+ # @return [Hash{String => Person}]
+ attr_reader :tokens
+
+ #
+ # Initializes the campaign.
+ #
+ # @param [Email::Template] email_template
+ # The email template to render.
+ #
+ # @param [Hash] options
+ # Additional options.
+ #
+ # @option options [String, URI::HTTP] :link
+ # The link to use.
+ #
+ # @option options [Hash] :attachment
+ # The attachment to embed. See {Attachment#initialize} for options.
+ #
+ # @option options [Person] :account
+ # The account that emails will be sent from.
+ #
+ # @option options [Array] :accounts
+ # The accounts to send emails from.
+ #
+ # @option options [Array] :recipients
+ # The intended recipients of the campaign.
+ #
+ # @option options [String] :department
+ # Targets all people in the organization's department.
+ #
+ # @option options [String] :location
+ # Targets all people in the given location.
+ #
+ # @raise [ArgumentError]
+ # The email template requires a `:accounts`, `:link` or `:attachment`
+ # option.
+ #
+ def initialize(email_template,organization,options={})
+ @organization = organization
+ @email_template = email_template
+
+ @mailer = options[:mailer]
+
+ if options[:link]
+ @link = Link.new(options[:link])
+ elsif @email_template.requires_link?
+ raise(ArgumentError,"email template requires a link")
+ end
+
+ if options[:tracking_pixel]
+ @tracking_pixel = options[:tracking_pixel]
+ end
+
+ if options[:attachment]
+ @attachment = Attachment.new(options[:attachment])
+ elsif @email_template.requires_attachment?
+ raise(ArgumentError,"email template requires an attachment")
+ end
+
+ @recipients = if options[:recipients]
+ options[:recipients]
+ elsif options[:department]
+ organization.people_in_department(options[:department])
+ elsif options[:location]
+ organization.people_from_location(options[:location])
+ else
+ organization.people
+ end
+
+ @accounts = []
+
+ if options[:account]
+ @accounts << options[:account]
+ elsif options[:accounts]
+ @accounts += options[:accounts]
+ elsif @email_template.requires_from?
+ raise(ArgumentError,"email template requires at least one account")
+ end
+
+ @tokens = {}
+
+ @recipients.each do |recipient|
+ @tokens[new_token] = recipient
+ end
+ end
+
+ #
+ # Lookups the token for the given recipient.
+ #
+ # @param [Recipient] recipient
+ # The recipient to search for.
+ #
+ # @return [String, nil]
+ # The token for the recipient.
+ #
+ def token_for(recipient)
+ token, _ = @tokens.find do |token,recipient|
+ recipient == recipient
+ end
+
+ return token
+ end
+
+ #
+ # Generates emails for each recipient.
+ #
+ # @yield [email]
+ # The given block will be passed each new message.
+ #
+ # @yieldparam [Mail::Message] email
+ # A newly generated email message.
+ #
+ def each_email
+ return enum_for(__method__) unless block_given?
+
+ @recipients.each do |recipient|
+ yield email(recipient,token_for(recipient))
+ end
+ end
+
+ #
+ # Generates an email.
+ #
+ # @param [Person] to
+ # The person sending the email.
+ #
+ # @param [String] token
+ # Unique token to identify the recipient with.
+ #
+ # @param [Person] from
+ # The account to send the email from.
+ #
+ # @return [Mail::Message]
+ # The newly generated email.
+ #
+ # @raise [RuntimeError]
+ # No sending account was given.
+ #
+ def email(to,token,from=@accounts.sample)
+ unless from
+ raise(ArgumentError,"no sending account given")
+ end
+
+ variables = to_liquid.merge(
+ 'to' => to.to_liquid,
+ 'from' => from.to_liquid,
+ 'token' => token
+ )
+
+ email = @email_template.render(variables)
+
+ if @attachment
+ email.add_file(
+ filename: @attachment.name,
+ content: @attachment.content
+ )
+ end
+
+ return email
+ end
+
+ #
+ # Converts the campaign to a liquid hash.
+ #
+ # @return [Hash{String => Hash}]
+ # The liquid hash containing:
+ #
+ # * `organization`
+ # * `link`
+ # * `attachment`
+ #
+ def to_liquid
+ {
+ 'organization' => @organization.to_liquid,
+ 'link' => (@link.to_liquid if @link),
+ 'attachment' => (@attachment.to_liquid if @attachment)
+ }
+ end
+
+ private
+
+ def new_token
+ Chars::ALPHA_NUMERIC.random_string(12)
+ end
+
+ end
+end
diff --git a/lib/tackle_box/email.rb b/lib/tackle_box/email.rb
new file mode 100644
index 0000000..2f73968
--- /dev/null
+++ b/lib/tackle_box/email.rb
@@ -0,0 +1 @@
+require 'tackle_box/email/cloner'
diff --git a/lib/tackle_box/email/cloner.rb b/lib/tackle_box/email/cloner.rb
new file mode 100644
index 0000000..2839cd1
--- /dev/null
+++ b/lib/tackle_box/email/cloner.rb
@@ -0,0 +1,88 @@
+require 'mail'
+require 'nokogiri'
+require 'uri'
+
+module TackleBox
+ module Email
+ module Cloner
+
+ # The link tag to replace all links with
+ LINK_TAG = '{% link %}'
+
+ #
+ # Clones the email.
+ #
+ # @param [String] email
+ # The raw email message.
+ #
+ # @param [Hash] attributes
+ # Additional attributes for the new email template.
+ #
+ # @return [EmailTemplate]
+ # The new email template.
+ #
+ def self.clone(email,attributes={})
+ email = Mail.read_from_string(email)
+
+ html = email.parts.find { |part| part.mime_type == 'text/html' }
+ text = email.parts.find { |part| part.mime_type == 'text/plain' }
+
+ body = if html then rewrite_html(html.decoded)
+ elsif text then rewrite_text(text.decoded)
+ else rewrite_text(email.body.decoded)
+ end
+
+ return EmailTemplate.new(attributes.merge(
+ subject: email.subject,
+ body: body
+ ))
+ end
+
+ #
+ # Rewrites the HTML replacing every `a` link with the `{% link %}` tag.
+ #
+ # @param [String] html
+ # The raw HTML to rewrite.
+ #
+ # @return [String]
+ # The rewritten HTML.
+ #
+ def self.rewrite_html(html)
+ doc = Nokogiri::HTML(html)
+ body = doc.at('//body')
+
+ place_holder = 'LINK_GOES_HERE'
+
+ body.search('//a/@href').each do |href|
+ href.value = place_holder
+ end
+
+ html = body.inner_html
+ html.strip!
+ html.gsub!(place_holder,LINK_TAG)
+
+ return html
+ end
+
+ #
+ # Rewrites the text replacing every URL with the `{% link %}` tag.
+ #
+ # @param [String] text
+ # The raw text.
+ #
+ # @return [String]
+ # The rewritten text.
+ #
+ def self.rewrite_text(text)
+ links = URI.extract(text,['http', 'https']).uniq
+
+ links.each do |link|
+ text.gsub!(link,LINK_TAG)
+ end
+
+ return text
+ end
+
+ end
+ end
+end
diff --git a/lib/tackle_box/email/editor.rb b/lib/tackle_box/email/editor.rb
new file mode 100644
index 0000000..2598469
--- /dev/null
+++ b/lib/tackle_box/email/editor.rb
@@ -0,0 +1,211 @@
+require 'mail'
+
+module TackleBox
+ module Email
+ class Editor
+
+ # Path to the email
+ #
+ # @return [String]
+ attr_reader :path
+
+ # The parsed email
+ #
+ # @return [Mail::Message]
+ attr_reader :email
+
+ #
+ # Initializes the email editor.
+ #
+ # @param [String] path
+ # The path to the email.
+ #
+ def initialize(path)
+ @path = path
+ @email = Mail.read(@path)
+
+ @to = @email.header[:to].address_list.addresses.first
+ @from = @email.header[:from].address_list.addresses.first
+ end
+
+ #
+ # Yields the email for modification.
+ #
+ # @yield [email]
+ # The given block will be passed the parsed email.
+ #
+ # @yieldparam [Mail::Message] email
+ # The parsed email.
+ #
+ def edit
+ yield @email
+
+ # force a re-parse of the modified email
+ @email = Mail.read_from_string(@email.to_s)
+ end
+
+ #
+ # Enumerates over each body part within the email.
+ #
+ # @yield [part]
+ # The given block will be passed each body party.
+ #
+ # @yieldparam [Mail::Message, Mail::Part] part
+ # A body part from the email. If the email only has a single body
+ # part, the entire `Mail::Mesage` will be yielded.
+ #
+ def edit_body_parts(&block)
+ return enum_for(__method__) unless block_given?
+
+ edit do |email|
+ unless email.parts.empty? then email.parts.each(&block)
+ else yield email
+ end
+ end
+ end
+
+ #
+ # De-`quote-printable`s the email.
+ #
+ def dequote
+ edit_body_parts do |part|
+ if part.content_transfer_encoding == 'quoted-printable'
+ part.content_transfer_encoding = '7bit'
+ part.body = part.decoded
+ end
+ end
+ end
+
+ #
+ # Performs global find and replace on the email.
+ #
+ # @param [Regexp, String] pattern
+ # The pattern to replace.
+ #
+ # @param [String, nil] substitute
+ # The substitute data.
+ #
+ # @yield [match]
+ # If a block is given, then it will be used to transform the matched
+ # data.
+ #
+ # @yieldparam [MatchData] match
+ # The match data.
+ #
+ def replace(pattern,substitute=nil,&block)
+ gsub = lambda { |str|
+ str.gsub(pattern,substitute,&block)
+ }
+
+ edit do |email|
+ email.header = gsub.call(email.header.raw_source)
+ end
+
+ edit_body_parts do |part|
+ part.body = gsub.call(part.body.decoded)
+ end
+ end
+
+ #
+ # Replaces the old name with a new name.
+ #
+ # @param [String] old_name
+ # The old name to replace.
+ #
+ # @param [String] new_name
+ # The new name to replace with.
+ #
+ def replace_name(old_name,new_name)
+ old_first_name, old_last_name = old_name.split(' ',2)
+ new_first_name, new_last_name = new_name.split(' ',2)
+
+ replace(old_name,new_name)
+ replace(old_last_name,new_last_name)
+ replace(old_first_name,new_first_name)
+ end
+
+ #
+ # Replaces all `From:` addresses globally.
+ #
+ # @param [String] new_from
+ # The new `From:` address.
+ #
+ def replace_from(new_from='{{ from.address }}')
+ replace(@from.address,new_from)
+ end
+
+ #
+ # Replaces the name in the `From:` header globally.
+ #
+ # @param [String] new_name
+ # The new name to use.
+ #
+ def replace_from_name(new_name=nil)
+ if @from.display_name
+ if new_name
+ replace_name(@from.display_name,new_name)
+ else
+ replace(@from.display_name,'{{ from.name }}')
+ end
+ end
+ end
+
+ #
+ # Replaces the `To:` address globally.
+ #
+ # @param [String] new_addr
+ # The new `To:` address to use.
+ #
+ def replace_to(new_addr='{{ to.address }}')
+ replace(@to.address,new_addr)
+ end
+
+ #
+ # Replaces the name in the `To:` address globally.
+ #
+ # @param [String] new_name
+ # The new name to use.
+ #
+ def replace_to_name(new_name=nil)
+ if @to.display_name
+ if new_name
+ replace_name(@to.display_name,new_name)
+ else
+ replace(@to.display_name,'{{ to.name }}')
+ end
+ end
+ end
+
+ #
+ # Replaces the domain globally.
+ #
+ # @param [String] domain
+ # The domain to replace.
+ #
+ # @param [String] new_domain
+ # The new domain to use.
+ #
+ def replace_domain(domain,new_domain='{{ link.host }}')
+ replace(domain,new_domain)
+ end
+
+ #
+ # Dumps out the edited email.
+ #
+ # @return [String]
+ # The raw email.
+ #
+ def to_s
+ @email.to_s
+ end
+
+ #
+ # Saves the edited email.
+ #
+ def save
+ File.write(@path,@email)
+ end
+
+ end
+ end
+end
diff --git a/lib/tackle_box/email/sender.rb b/lib/tackle_box/email/sender.rb
new file mode 100644
index 0000000..26ccb7f
--- /dev/null
+++ b/lib/tackle_box/email/sender.rb
@@ -0,0 +1,45 @@
+require 'mail'
+
+module TackleBox
+ module Email
+ class Sender
+
+ # The SMTP credentials grouped by domain.
+ #
+ # @return [Hash{String => Hash}]
+ attr_reader :domains
+
+ #
+ # Initializes the sender.
+ #
+ # @param [Hash{String => Hash}] domains
+ # SMTP credentials grouped by domain.
+ #
+ def initialize(domains)
+ @domains = domains
+ end
+
+ #
+ # Sends the email via the appropriate SMTP server.
+ #
+ # @param [Mail::Message] email
+ # The email to send.
+ #
+ # @raise [RuntimeError]
+ # Could not find SMTP credentials for the from address in the email.
+ #
+ def send(email)
+ from = email.from_addrs.first
+ domain = from.split('@',2).last
+
+ credentials = @domains.fetch(domain) do
+ raise("no credentials for SMTP server: #{domain}")
+ end
+
+ email.delivery_method :smtp, credentials
+ email.deliver
+ end
+
+ end
+ end
+end
diff --git a/lib/tackle_box/email/template.rb b/lib/tackle_box/email/template.rb
new file mode 100644
index 0000000..fea8490
--- /dev/null
+++ b/lib/tackle_box/email/template.rb
@@ -0,0 +1,131 @@
+require 'tackle_box/email/template/extensions'
+require 'tackle_box/email/template/filters'
+require 'tackle_box/email/template/tags'
+
+require 'mail'
+require 'liquid'
+require 'nokogiri'
+
+module TackleBox
+ module Email
+ class Template
+
+ # The email to be rendered.
+ #
+ # @return [String]
+ attr_reader :email
+
+ #
+ # Initializes the email template.
+ #
+ # @param [String] email
+ # The raw email to render.
+ #
+ def initialize(email)
+ @email = email
+ end
+
+ #
+ # Loads an email template from a file.
+ #
+ # @param [String] path
+ # Path to the `.eml` file.
+ #
+ # @return [Template]
+ # The loaded template.
+ #
+ def self.open(path)
+ new(File.read(path))
+ end
+
+ #
+ # Determines if the email template requires the `to` variable.
+ #
+ # @return [Boolean]
+ # Specifies whether the email template uses the `to` variable.
+ #
+ def requires_to?
+ @email.include?('{{ to.')
+ end
+
+ #
+ # Determines if the email template requires the `from` variable.
+ #
+ # @return [Boolean]
+ # Specifies whether the email template uses the `from` variable.
+ #
+ def requires_from?
+ @email.include?('{{ from.')
+ end
+
+ #
+ # Determines if the email template requires the `link` variable.
+ #
+ # @return [Boolean]
+ # Specifies whether the email template uses the `link` variable.
+ #
+ def requires_link?
+ @email.include?('{% link %}') ||
+ @email.include?('{{ link.')
+ end
+
+ #
+ # Determines if the email template requires the `attachment` variable.
+ #
+ # @return [Boolean]
+ # Specifies whether the email template uses the `attachment` variable.
+ #
+ def requires_attachment?
+ @email.include?('{% attachment %}') ||
+ @email.include?('{{ attachment.')
+ end
+
+ #
+ # Renders the given liquid text.
+ #
+ # @param [String] text
+ # The text containing liquid tags.
+ #
+ # @param [Hash{String => String,Array,Hash}] variables
+ # Liquid template variables.
+ #
+ # @return [String]
+ # The rendered text.
+ #
+ def render_liquid(text,variables)
+ liquid = Liquid::Template.parse(text)
+ liquid.render(variables)
+ end
+
+ #
+ # Renders the email.
+ #
+ # @param [Hash{String => String,Array,Hash}] variables
+ # Liquid template variables.
+ #
+ # @return [String]
+ # The rendered email.
+ #
+ def render(variables)
+ new_email = Mail.read_from_string(@email)
+ new_email.header = render_liquid(new_email.header.raw_source,variables)
+
+ render_and_replace = lambda { |part|
+ # only render text/plain and text/html parts
+ if part.content_type =~ %r{^text/(plain|html)}
+ part.body = render_liquid(part.body.decoded,variables)
+ end
+ }
+
+ unless new_email.body.parts.empty?
+ new_email.body.parts.each(&render_and_replace)
+ else
+ render_and_replace.call(new_email)
+ end
+
+ return new_email
+ end
+
+ end
+ end
+end
diff --git a/lib/tackle_box/email/template/extensions.rb b/lib/tackle_box/email/template/extensions.rb
new file mode 100644
index 0000000..a5e1163
--- /dev/null
+++ b/lib/tackle_box/email/template/extensions.rb
@@ -0,0 +1 @@
+require 'tackle_box/email/template/extensions/uri'
diff --git a/lib/tackle_box/email/template/extensions/uri.rb b/lib/tackle_box/email/template/extensions/uri.rb
new file mode 100644
index 0000000..7953fc4
--- /dev/null
+++ b/lib/tackle_box/email/template/extensions/uri.rb
@@ -0,0 +1 @@
+require 'tackle_box/email/template/extensions/uri/http'
diff --git a/lib/tackle_box/email/template/extensions/uri/http.rb b/lib/tackle_box/email/template/extensions/uri/http.rb
new file mode 100644
index 0000000..bc3fe13
--- /dev/null
+++ b/lib/tackle_box/email/template/extensions/uri/http.rb
@@ -0,0 +1,25 @@
+require 'uri/http'
+
+module URI
+ class HTTP < Generic
+
+ #
+ # Breaks the HTTP URI down into a liquid Hash of it's components.
+ #
+ # @return [Hash{String => String}]
+ # The liquid Hash containing `url`, `scheme`, `host`, `port`, `path`
+ # and `query`.
+ #
+ def to_liquid
+ {
+ 'url' => self.to_s,
+ 'scheme' => self.scheme,
+ 'host' => self.host,
+ 'port' => self.port,
+ 'path' => self.path,
+ 'query' => self.query
+ }
+ end
+
+ end
+end
diff --git a/lib/tackle_box/email/template/filters.rb b/lib/tackle_box/email/template/filters.rb
new file mode 100644
index 0000000..37d99f4
--- /dev/null
+++ b/lib/tackle_box/email/template/filters.rb
@@ -0,0 +1,78 @@
+require 'liquid'
+
+require 'active_support/inflector'
+
+module TackleBox
+ module Email
+ class Template
+ module Filters
+ #
+ # Converts the input to all lower-case.
+ #
+ # @param [String] input
+ # The input.
+ #
+ # @return [String]
+ # The lower-cased output.
+ #
+ def lowercase(input)
+ input.downcase
+ end
+
+ #
+ # Converts the input to all upper-case.
+ #
+ # @param [String] input
+ # The input.
+ #
+ # @return [String]
+ # The upper-cased output.
+ #
+ def uppercase(input)
+ input.upcase
+ end
+
+ #
+ # Capitalizes the first character of the input.
+ #
+ # @param [String] input
+ # The input.
+ #
+ # @return [String]
+ # The capitalized output.
+ #
+ def capitalize(input)
+ input.capitalize
+ end
+
+ #
+ # Pluralizes the input.
+ #
+ # @param [String] input
+ # The input.
+ #
+ # @return [String]
+ # The pluralized output.
+ #
+ def pluralize(input)
+ ActiveSupport::Inflector.pluralize(input)
+ end
+
+ #
+ # Singularizes the input.
+ #
+ # @param [String] input
+ # The input.
+ #
+ # @return [String]
+ # The singularized output.
+ #
+ def singularize(input)
+ ActiveSupport::Inflector.singularize(input)
+ end
+ end
+ end
+ end
+end
+
+Liquid::Template.register_filter(TackleBox::Email::Template::Filters)
diff --git a/lib/tackle_box/email/template/tags.rb b/lib/tackle_box/email/template/tags.rb
new file mode 100644
index 0000000..1dda5e9
--- /dev/null
+++ b/lib/tackle_box/email/template/tags.rb
@@ -0,0 +1,12 @@
+require 'tackle_box/email/template/tags/day'
+require 'tackle_box/email/template/tags/week_day'
+require 'tackle_box/email/template/tags/date'
+require 'tackle_box/email/template/tags/year'
+
+require 'tackle_box/email/template/tags/random_day'
+require 'tackle_box/email/template/tags/random_week_day'
+require 'tackle_box/email/template/tags/random_date'
+require 'tackle_box/email/template/tags/random_greeting'
+
+require 'tackle_box/email/template/tags/attachment'
+require 'tackle_box/email/template/tags/link'
diff --git a/lib/tackle_box/email/template/tags/attachment.rb b/lib/tackle_box/email/template/tags/attachment.rb
new file mode 100644
index 0000000..41c47f6
--- /dev/null
+++ b/lib/tackle_box/email/template/tags/attachment.rb
@@ -0,0 +1,34 @@
+require 'liquid'
+
+module TackleBox
+ module Email
+ class Template
+ module Tags
+ class Attachment < Liquid::Tag
+
+ TEXT = [
+ 'password:',
+ 'pass:',
+ 'passwd:',
+ 'the password is'
+ ]
+
+ def render(context)
+ if (attachment = context['attachment'])
+ text = attachment['name'].dup
+
+ if attachment['password']
+ text << " (#{TEXT.sample} #{attachment['password']})"
+ end
+
+ return text
+ end
+ end
+
+ end
+ end
+ end
+ end
+end
+
+Liquid::Template.register_tag('attachment', TackleBox::Email::Template::Tags::Attachment)
diff --git a/lib/tackle_box/email/template/tags/date.rb b/lib/tackle_box/email/template/tags/date.rb
new file mode 100644
index 0000000..de87d19
--- /dev/null
+++ b/lib/tackle_box/email/template/tags/date.rb
@@ -0,0 +1,20 @@
+require 'liquid'
+require 'date'
+
+module TackleBox
+ module Email
+ class Template
+ module Tags
+ class Date < Liquid::Tag
+
+ def render(context)
+ ::Date.today.strftime("%b %e")
+ end
+
+ end
+ end
+ end
+ end
+end
+
+Liquid::Template.register_tag('date', TackleBox::Email::Template::Tags::Date)
diff --git a/lib/tackle_box/email/template/tags/day.rb b/lib/tackle_box/email/template/tags/day.rb
new file mode 100644
index 0000000..7bffb00
--- /dev/null
+++ b/lib/tackle_box/email/template/tags/day.rb
@@ -0,0 +1,20 @@
+require 'liquid'
+require 'date'
+
+module TackleBox
+ module Email
+ class Template
+ module Tags
+ class Day < Liquid::Tag
+
+ def render(context)
+ ::Date.today.strftime("%e").strip
+ end
+
+ end
+ end
+ end
+ end
+end
+
+Liquid::Template.register_tag('day', TackleBox::Email::Template::Tags::Day)
diff --git a/lib/tackle_box/email/template/tags/link.rb b/lib/tackle_box/email/template/tags/link.rb
new file mode 100644
index 0000000..ff52fbc
--- /dev/null
+++ b/lib/tackle_box/email/template/tags/link.rb
@@ -0,0 +1,45 @@
+require 'liquid'
+require 'uri'
+require 'shorturl'
+
+module TackleBox
+ module Email
+ class Template
+ module Tags
+ class Link < Liquid::Tag
+
+ TOKEN_PARAM = 'token'
+
+ def render(context)
+ if (link = context['link'])
+
+ url = URI::HTTP.build(
+ host: link['host'],
+ port: link['port'],
+ path: link['path'],
+ query: link['query']
+ )
+
+ param = "#{TOKEN_PARAM}=#{context['token']}"
+
+ if (url.query.nil? || url.query.empty?)
+ url.query = param
+ else
+ url.query << "{param}"
+ end
+
+ if context['link_shortener']
+ url = ShortURL.shorten(url.to_s,context['link_shortener'].to_sym)
+ end
+
+ return url
+ end
+ end
+
+ end
+ end
+ end
+ end
+end
+
+Liquid::Template.register_tag('link', TackleBox::Email::Template::Tags::Link)
diff --git a/lib/tackle_box/email/template/tags/random_date.rb b/lib/tackle_box/email/template/tags/random_date.rb
new file mode 100644
index 0000000..7aa6d47
--- /dev/null
+++ b/lib/tackle_box/email/template/tags/random_date.rb
@@ -0,0 +1,21 @@
+require 'liquid'
+
+module TackleBox
+ module Email
+ class Template
+ module Tags
+ class RandomDate < Liquid::Tag
+
+ def render(context)
+ today = Date.today
+
+ return today.strftime("%b #{rand(today.day - 1) + 1}")
+ end
+
+ end
+ end
+ end
+ end
+end
+
+Liquid::Template.register_tag('random_date', TackleBox::Email::Template::Tags::RandomDate)
diff --git a/lib/tackle_box/email/template/tags/random_day.rb b/lib/tackle_box/email/template/tags/random_day.rb
new file mode 100644
index 0000000..acecb01
--- /dev/null
+++ b/lib/tackle_box/email/template/tags/random_day.rb
@@ -0,0 +1,19 @@
+require 'liquid'
+
+module TackleBox
+ module Email
+ class Template
+ module Tags
+ class RandomDay < Liquid::Tag
+
+ def render(context)
+ rand(Date.today.day - 1) + 1
+ end
+
+ end
+ end
+ end
+ end
+end
+
+Liquid::Template.register_tag('random_day', TackleBox::Email::Template::Tags::RandomDay)
diff --git a/lib/tackle_box/email/template/tags/random_greeting.rb b/lib/tackle_box/email/template/tags/random_greeting.rb
new file mode 100644
index 0000000..fe6fd39
--- /dev/null
+++ b/lib/tackle_box/email/template/tags/random_greeting.rb
@@ -0,0 +1,27 @@
+require 'liquid'
+
+module TackleBox
+ module Email
+ class Template
+ module Tags
+ class RandomGreeting < Liquid::Tag
+
+ GREETINGS = [
+ 'Good day',
+ 'How are you doing',
+ 'I hope everything is well with you',
+ 'Dear',
+ 'Hello'
+ ]
+
+ def render(context)
+ GREETINGS.sample
+ end
+
+ end
+ end
+ end
+ end
+end
+
+Liquid::Template.register_tag('random_greeting', TackleBox::Email::Template::Tags::RandomGreeting)
diff --git a/lib/tackle_box/email/template/tags/random_week_day.rb b/lib/tackle_box/email/template/tags/random_week_day.rb
new file mode 100644
index 0000000..ba998f0
--- /dev/null
+++ b/lib/tackle_box/email/template/tags/random_week_day.rb
@@ -0,0 +1,21 @@
+require 'liquid'
+
+module TackleBox
+ module Email
+ class Template
+ module Tags
+ class RandomWeekDay < Liquid::Tag
+
+ DAYS = %w[Monday Tuesday Wednesday Thursday Friday]
+
+ def render(context)
+ DAYS.sample
+ end
+
+ end
+ end
+ end
+ end
+end
+
+Liquid::Template.register_tag('random_week_day', TackleBox::Email::Template::Tags::RandomWeekDay)
diff --git a/lib/tackle_box/email/template/tags/week_day.rb b/lib/tackle_box/email/template/tags/week_day.rb
new file mode 100644
index 0000000..fd4cef2
--- /dev/null
+++ b/lib/tackle_box/email/template/tags/week_day.rb
@@ -0,0 +1,20 @@
+require 'liquid'
+require 'date'
+
+module TackleBox
+ module Email
+ class Template
+ module Tags
+ class WeekDay < Liquid::Tag
+
+ def render(context)
+ ::Date.today.strftime("%A")
+ end
+
+ end
+ end
+ end
+ end
+end
+
+Liquid::Template.register_tag('week_day', TackleBox::Email::Template::Tags::WeekDay)
diff --git a/lib/tackle_box/email/template/tags/year.rb b/lib/tackle_box/email/template/tags/year.rb
new file mode 100644
index 0000000..458ca00
--- /dev/null
+++ b/lib/tackle_box/email/template/tags/year.rb
@@ -0,0 +1,20 @@
+require 'liquid'
+require 'date'
+
+module TackleBox
+ module Email
+ class Template
+ module Tags
+ class Year < Liquid::Tag
+
+ def render(context)
+ ::Date.today.year.to_s
+ end
+
+ end
+ end
+ end
+ end
+end
+
+Liquid::Template.register_tag('year', TackleBox::Email::Template::Tags::Year)
diff --git a/lib/tackle_box/link.rb b/lib/tackle_box/link.rb
new file mode 100644
index 0000000..eb7533d
--- /dev/null
+++ b/lib/tackle_box/link.rb
@@ -0,0 +1,59 @@
+require 'uri'
+
+module TackleBox
+ class Link
+
+ #
+ # Initializes the link.
+ #
+ # @param [String, URI::HTTP] link
+ # The URI of the link.
+ #
+ def initialize(link)
+ @url = URI(link)
+ end
+
+ #
+ # The domain that is serving the link.
+ #
+ # @return [String]
+ #
+ def domain
+ @url.host
+ end
+
+ #
+ # Converts the link into a liquid hash.
+ #
+ # @return [Hash{String => Integer,String}]
+ # The liquid hash containing:
+ #
+ # * `url`
+ # * `scheme`
+ # * `host`
+ # * `port`
+ # * `path`
+ # * `query`
+ #
+ def to_liquid
+ {
+ 'url' => @url.to_s,
+ 'scheme' => @url.scheme,
+ 'host' => @url.host,
+ 'port' => @url.port,
+ 'path' => @url.path,
+ 'query' => @url.query
+ }
+ end
+
+ #
+ # Converts the link to a String.
+ #
+ # @return [String]
+ #
+ def to_s
+ @url.to_s
+ end
+
+ end
+end
diff --git a/lib/tackle_box/organization.rb b/lib/tackle_box/organization.rb
new file mode 100644
index 0000000..604d0e4
--- /dev/null
+++ b/lib/tackle_box/organization.rb
@@ -0,0 +1,124 @@
+require 'tackle_box/person'
+
+module TackleBox
+ class Organization
+
+ # The name of the organization.
+ #
+ # @return [String]
+ attr_reader :name
+
+ # The primary domain of the organization.
+ #
+ # @return [String]
+ attr_reader :domain
+
+ # The website of the organization.
+ #
+ # @return [String]
+ attr_reader :website
+
+ # The industry sector of the organization.
+ #
+ # @return [String]
+ attr_reader :industry
+
+ # The people belonging to the organization.
+ #
+ # @return [Array]
+ attr_reader :people
+
+ #
+ # Initializes the organization.
+ #
+ # @param [String] name
+ #
+ # @option options [String] :domain
+ #
+ # @option options [String] :website
+ #
+ # @option options [String] :industry
+ #
+ def initialize(name,options={})
+ @name = name
+
+ @domain = options[:domain]
+ @website = options.fetch(:website) do
+ "http://#{@domain}" if @domain
+ end
+ @industry = options[:industry]
+
+ @people = []
+ end
+
+ #
+ # Adds a person to the organization.
+ #
+ # @param [String] address
+ # Email address for the person.
+ #
+ # @param [Hash] options
+ # Additional options, see {Person#initialize}.
+ #
+ def person(address,options={})
+ @people << Person.new(address,options)
+ end
+
+ #
+ # Searches for people in the given department.
+ #
+ # @param [String] dept
+ # The department to search against.
+ #
+ # @return [Array]
+ # The people belonging to that department.
+ #
+ def people_in_department(dept)
+ @people.find { |person| person.department == dept }
+ end
+
+ #
+ # Searches for people from a given location.
+ #
+ # @param [String] loc
+ # The location to search against.
+ #
+ # @return [Array]
+ # The people in that location.
+ #
+ def people_from_location(loc)
+ @people.find { |person| person.location == loc }
+ end
+
+ #
+ # Converts the organization to a liquid Hash.
+ #
+ # @return [Hash{String => String,nil}]
+ # The liquid hash containing:
+ #
+ # * `name`
+ # * `domain`
+ # * `website`
+ # * `industry`
+ #
+ def to_liquid
+ {
+ 'name' => @name,
+ 'domain' => @domain,
+ 'website' => @website,
+ 'industry' => @industry
+ }
+ end
+
+ #
+ # Converts the organization to a String.
+ #
+ # @return [String]
+ # The name of the organization.
+ #
+ def to_s
+ @name
+ end
+
+ end
+end
diff --git a/lib/tackle_box/person.rb b/lib/tackle_box/person.rb
new file mode 100644
index 0000000..c7ff410
--- /dev/null
+++ b/lib/tackle_box/person.rb
@@ -0,0 +1,90 @@
+module TackleBox
+ class Person
+
+ # The email address for the person
+ #
+ # @return [String]
+ attr_reader :address
+
+ # The person's name
+ #
+ # @return [String, nil]
+ attr_reader :name
+
+ # The person's job title
+ #
+ # @return [String, nil]
+ attr_reader :title
+
+ # The person's department
+ #
+ # @return [String, nil]
+ attr_reader :department
+
+ # The person's physical location.
+ #
+ # @return [String, nil]
+ attr_reader :location
+
+ #
+ # Initializes the person.
+ #
+ # @param [String] address
+ # The email address of the person.
+ #
+ # @param [Hash] options
+ # Additional options.
+ #
+ # @option options [String] :name
+ # The name of the person.
+ #
+ # @option options [String] :title
+ # The job title of the person.
+ #
+ # @option options [String] :department
+ # The department the person works in.
+ #
+ def initialize(address,options={})
+ @address = address
+
+ @name = options[:name]
+ @title = options[:title]
+ @department = options[:department]
+ @location = options[:location]
+ end
+
+ #
+ # Converts the person to a liquid Hash.
+ #
+ # @return [Hash{String => String,nil}]
+ # The liquid hash containing:
+ #
+ # * `name`
+ # * `address`
+ # * `title`
+ # * `department`
+ #
+ def to_liquid
+ {
+ 'name' => @name,
+ 'address' => @address,
+ 'title' => @title,
+ 'department' => @department,
+ 'location' => @location
+ }
+ end
+
+ #
+ # Converts the person to a String.
+ #
+ # @return [String]
+ # The person's name and address.
+ #
+ def to_s
+ if @name then "#{@name} <#{@address}>"
+ else @address.to_s
+ end
+ end
+
+ end
+end
diff --git a/lib/tackle_box/version.rb b/lib/tackle_box/version.rb
new file mode 100644
index 0000000..9c1bcef
--- /dev/null
+++ b/lib/tackle_box/version.rb
@@ -0,0 +1,4 @@
+module TackleBox
+ # tacklebox version
+ VERSION = "0.1.0"
+end
diff --git a/spec/attachment_spec.rb b/spec/attachment_spec.rb
new file mode 100644
index 0000000..3cd4633
--- /dev/null
+++ b/spec/attachment_spec.rb
@@ -0,0 +1,107 @@
+require 'spec_helper'
+require 'tackle_box/attachment'
+
+describe TackleBox::Attachment do
+ describe "#initialize" do
+ context "when neither :path or :content are given" do
+ it "should raise an ArgumentError" do
+ expect {
+ described_class.new
+ }.to raise_error(ArgumentError,"must specify :path or :content")
+ end
+ end
+
+ context "when :path is given" do
+ let(:path) { __FILE__ }
+
+ subject { described_class.new(path: path) }
+
+ it "should set the path" do
+ expect(subject.path).to be path
+ end
+
+ context "when :name is also given" do
+ let(:name) { "nudes.zip" }
+
+ subject { described_class.new(path: path, name: name) }
+
+ it "should override the default :name" do
+ expect(subject.name).to be name
+ end
+ end
+
+ context "when :name is not given" do
+ it "should derive the name from the path" do
+ expect(subject.name).to be == File.basename(path)
+ end
+ end
+
+ context "when :content is not given" do
+ it "should default to the files contents" do
+ expect(subject.content).to be == File.binread(path)
+ end
+ end
+ end
+
+ context "when :content is given" do
+ context "when :name is not given" do
+ it "should raise an ArgumentError" do
+ expect {
+ described_class.new(content: "foo")
+ }.to raise_error(ArgumentError,":path or :name are required")
+ end
+ end
+
+ context "when :name is given" do
+ let(:name) { "nudes.zip" }
+ let(:content) { "foo bar" }
+
+ subject { described_class.new(name: name, content: content) }
+
+ it "should set content and name" do
+ expect(subject.name).to be name
+ expect(subject.content).to be content
+ end
+ end
+ end
+
+ context "when :encoding is not given" do
+ let(:name) { 'nudes.zip' }
+ let(:content) { "foo bar" }
+ let(:encoding) { 'zip' }
+
+ subject { described_class.new(name: name, content: content) }
+
+ it "should derive the encoding from the attachment name" do
+ expect(subject.encoding).to be == encoding
+ end
+ end
+ end
+
+ describe "#to_liquid" do
+ let(:name) { 'nudes.zip' }
+ let(:encoding) { 'zip' }
+ let(:password) { "infected" }
+
+ subject do
+ described_class.new(
+ name: name,
+ content: double,
+ encoding: encoding,
+ password: password
+ ).to_liquid
+ end
+
+ it "should contain the name" do
+ expect(subject['name']).to be name
+ end
+
+ it "should contain the encoding" do
+ expect(subject['encoding']).to be encoding
+ end
+
+ it "should contain the password" do
+ expect(subject['password']).to be password
+ end
+ end
+end
diff --git a/spec/campaign_spec.rb b/spec/campaign_spec.rb
new file mode 100644
index 0000000..e89620a
--- /dev/null
+++ b/spec/campaign_spec.rb
@@ -0,0 +1,92 @@
+require 'spec_helper'
+require 'tackle_box/campaign'
+
+describe TackleBox::Campaign do
+ let(:organization) do
+ Organization.new(
+ 'Trail of Bits, LLC',
+ domain: 'trailofbits.com',
+ website: 'http://www.trailofbits.com/',
+ industry: 'Information Security'
+ )
+ end
+
+ let(:to) do
+ Person.new('dan@trailofbits.com', name: 'Dan Guido',
+ title: 'CEO',
+ department: 'Management',
+ location: 'New York, NY')
+ end
+
+ let(:from) do
+ Person.new('nick@trailofberts.com', name: 'Nick De',
+ title: 'Chief Twitter Strategist',
+ department: 'Sales',
+ location: 'New York, NY')
+ end
+
+ let(:email_template) do
+ Email::Template.open(File.join(File.dirname(__FILE__),'email','multipart_email_template.eml'))
+ end
+
+ let(:link) { 'http://lurkdin.com/uas/request-password-reset?trk=signin_fpwd' }
+
+ let(:attachment) do
+ {
+ name: 'nudes.jpg',
+ content: 'infected'
+ }
+ end
+
+ subject do
+ described_class.new(email_template, organization, link: link,
+ account: from)
+ end
+
+ describe "#initialize" do
+ context "when a link option is given" do
+ it "should initialize a Link object" do
+ expect(subject.link.to_s).to be == link
+ end
+ end
+
+ context "when an attachment option is given" do
+ subject do
+ described_class.new(email_template,organization, link: link,
+ attachment: attachment,
+ account: from)
+ end
+
+ it "should initialize an Attachment object" do
+ expect(subject.attachment.name).to be == attachment[:name]
+ expect(subject.attachment.content).to be == attachment[:content]
+ end
+ end
+ end
+
+ describe "#to_liquid" do
+ let(:liquid) { subject.to_liquid }
+
+ it "should contain the organization hash" do
+ expect(liquid['organization']).to be == organization.to_liquid
+ end
+
+ context "when #link is set" do
+ it "should include a link hash" do
+ expect(liquid['link']).to be == subject.link.to_liquid
+ end
+ end
+
+ context "when #attachment is set" do
+ subject do
+ described_class.new(email_template,organization, link: link,
+ attachment: attachment,
+ account: from)
+ end
+
+ it "should include an attachment hash" do
+ expect(liquid['attachment']).to be == subject.attachment.to_liquid
+ end
+ end
+ end
+end
diff --git a/spec/email/editor_spec.rb b/spec/email/editor_spec.rb
new file mode 100644
index 0000000..f9ba214
--- /dev/null
+++ b/spec/email/editor_spec.rb
@@ -0,0 +1,198 @@
+require 'spec_helper'
+require 'tackle_box/email/editor'
+
+describe TackleBox::Email::Editor do
+ let(:email) { File.join(File.dirname(__FILE__),'email.eml') }
+
+ subject { described_class.new(email) }
+
+ describe "#initialize" do
+ it "should set path" do
+ expect(subject.path).to be email
+ end
+
+ it "should parse the email" do
+ expect(subject.email.class).to be Mail::Message
+ expect(subject.email.to_s).to be == File.read(email)
+ end
+ end
+
+ describe "#edit" do
+ it "should yield the email" do
+ expect { |b| subject.edit(&b) }.to yield_with_args(subject.email)
+ end
+ end
+
+ describe "#edit_body_parts" do
+ it "should return an Enumerator if no block is given" do
+ expect(subject.edit_body_parts.class).to be Enumerator
+ end
+
+ context "when the email multiple body parts" do
+ let(:email) { File.join(File.dirname(__FILE__),'multipart_email.eml') }
+
+ it "should yield each text/plain and text/html part" do
+ expect { |b|
+ subject.edit_body_parts(&b)
+ }.to yield_successive_args(*subject.email.parts)
+ end
+ end
+
+ context "when the email only has one body part" do
+ let(:email) { File.join(File.dirname(__FILE__),'email.eml') }
+
+ it "should yield the email itself" do
+ expect { |b| subject.edit_body_parts(&b) }.to yield_with_args(subject.email)
+ end
+ end
+ end
+
+ describe "#dequote" do
+ let(:email) do
+ File.join(File.dirname(__FILE__),'quoted_printable_email.eml')
+ end
+
+ let(:unquoted_email) do
+ Mail.read(File.join(File.dirname(__FILE__),'email.eml'))
+ end
+
+ before { subject.dequote }
+
+ it "should set Content-Transfer-Encoding to '7bit'" do
+ expect(subject.email.content_transfer_encoding).to be == '7bit'
+ end
+
+ it "should dequote the body" do
+ expect(subject.email.body.to_s).to be == unquoted_email.body.to_s
+ end
+ end
+
+ describe "#replace" do
+ it "should find and replace text in the headers" do
+ subject.replace('Dan Guido','Nick De')
+
+ expect(subject.email.header.raw_source).to_not include('Dan Guido')
+ end
+
+ it "should find and replace text in the body" do
+ subject.replace('nudes','nudez')
+
+ expect(subject.email.body.to_s).to_not include('nudes')
+ end
+ end
+
+ describe "#replace_name" do
+ let(:old_first_name) { 'Hal' }
+ let(:old_last_name) { 'Brodigan' }
+ let(:old_name) { "#{old_first_name} #{old_last_name}" }
+
+ let(:new_first_name) { 'Nick' }
+ let(:new_last_name) { 'Depetrillo' }
+ let(:new_name) { "#{new_first_name} #{new_last_name}" }
+
+ before { subject.replace_name(old_name,new_name) }
+
+ it "should replace all instances of the old name" do
+ expect(subject.email.to_s).to_not include(old_name)
+ expect(subject.email.to_s).to include(new_name)
+ end
+
+ it "should replace all instances of the old last name" do
+ expect(subject.email.to_s).to_not include(old_last_name)
+ expect(subject.email.to_s).to include(new_last_name)
+ end
+
+ it "should replace all instances of the old first name" do
+ expect(subject.email.to_s).to_not include(old_first_name)
+ expect(subject.email.to_s).to include(new_first_name)
+ end
+ end
+
+ describe "#replace_from" do
+ let(:old_from) { 'hal@trailofbits.com' }
+ let(:new_from) { 'nick@trailofbits.com' }
+
+ before { subject.replace_from(new_from) }
+
+ it "should replace the from address" do
+ expect(subject.email.to_s).to_not include(old_from)
+ expect(subject.email.from).to include(new_from)
+ end
+
+ context "with no argument" do
+ let(:new_from) { '{{ from.address }}' }
+
+ before { subject.replace_from }
+
+ it "should replace the from address with '{{ from.address }}'" do
+ expect(subject.email.to_s).to_not include(old_from)
+ expect(subject.email.from).to include(new_from)
+ end
+ end
+ end
+
+ describe "#replace_from_name" do
+ let(:old_from_name) { 'Hal Brodigan' }
+ let(:new_from_name) { 'Nick De' }
+
+ before { subject.replace_from_name(new_from_name) }
+
+ it "should replace the from address" do
+ expect(subject.email.to_s).to_not include(old_from_name)
+ expect(subject.email.header[:from].address_list.addresses.first.display_name).to be == new_from_name
+ end
+
+ context "with no argument" do
+ let(:new_from_name) { '{{ from.name }}' }
+
+ before { subject.replace_from_name }
+
+ it "should replace the from address with '{{ from.address }}'" do
+ expect(subject.email.to_s).to_not include(old_from_name)
+ expect(subject.email.header[:from].address_list.addresses.first.display_name).to be == new_from_name
+ end
+ end
+ end
+
+ describe "#replace_to" do
+ let(:old_to) { 'dan@trailofbits.com' }
+ let(:new_to) { '{{ to.address }}' }
+
+ before { subject.replace_to }
+
+ it "should replace the to address with '{{ to.address }}'" do
+ expect(subject.email.to_s).to_not include(old_to)
+ expect(subject.email.to).to include(new_to)
+ end
+ end
+
+ describe "#replace_to_name" do
+ let(:old_to_name) { 'Dan Guido' }
+ let(:new_to_name) { '{{ to.name }}' }
+
+ before { subject.replace_to_name }
+
+ it "should replace the to address with '{{ to.address }}'" do
+ expect(subject.email.to_s).to_not include(old_to_name)
+ expect(subject.email.header[:to].address_list.addresses.first.display_name).to be == new_to_name
+ end
+ end
+
+ describe "#replace_domain" do
+ let(:old_domain) { 'imgur.com' }
+ let(:new_domain) { 'imdurr.com' }
+
+ before { subject.replace_domain(old_domain,new_domain) }
+
+ it "should replace every domain with the new domain" do
+ expect(subject.email.to_s).to_not include(old_domain)
+ expect(subject.email.to_s).to include(new_domain)
+ end
+ end
+
+ describe "#to_s" do
+ it "should return the edited email" do
+ expect(subject.to_s).to be == subject.email.to_s
+ end
+ end
+end
diff --git a/spec/email/sender_spec.rb b/spec/email/sender_spec.rb
new file mode 100644
index 0000000..d9dfb67
--- /dev/null
+++ b/spec/email/sender_spec.rb
@@ -0,0 +1,54 @@
+require 'spec_helper'
+require 'tackle_box/email/sender'
+
+describe TackleBox::Email::Sender do
+ let(:domains) do
+ {
+ 'lurkedin.com' => {
+ address: 'mail.lurkedin.com',
+ port: 587,
+ user_name: 'test',
+ password: 'test',
+ authentication: :plain,
+ enable_starttls_auto: true
+ },
+
+ 'trailofbits.com' => {
+ address: 'mail.trailofbits.com',
+ port: 587,
+ user_name: 'test',
+ password: 'test',
+ authentication: :plain,
+ enable_starttls_auto: true
+ }
+ }
+ end
+
+ subject { described_class.new(domains) }
+
+ describe "#initialize" do
+ it "should set domains" do
+ expect(subject.domains).to be domains
+ end
+ end
+
+ describe "#send" do
+ let(:email) do
+ double('Mail::Message', from_addrs: ['hal@trailofbits.com'])
+ end
+
+ let(:expected_credentials) { domains['trailofbits.com'] }
+
+ before do
+ allow(email).to receive(:deliver)
+ end
+
+ it "should select the credentials based on the from domain" do
+ expect(email).to receive(:delivery_method).with(
+ :smtp, expected_credentials
+ )
+
+ subject.send(email)
+ end
+ end
+end
diff --git a/spec/email/template/extensions/uri/http_spec.rb b/spec/email/template/extensions/uri/http_spec.rb
new file mode 100644
index 0000000..75f5598
--- /dev/null
+++ b/spec/email/template/extensions/uri/http_spec.rb
@@ -0,0 +1,24 @@
+require 'spec_helper'
+
+require 'tackle_box/email/template/extensions/uri/http'
+
+describe URI::HTTP do
+ let(:link) { "http://lurkdin.com/uas/request-password-reset?trk=signin_fpwd" }
+ let(:uri) { URI(link) }
+
+ subject { uri }
+
+ describe "#to_liquid" do
+ subject { super().to_liquid }
+
+ it "should include 'url'" do
+ expect(subject['url']).to be == link
+ end
+
+ [:scheme, :host, :port, :path, :query].each do |component|
+ it "should include '#{component}'" do
+ expect(subject[component.to_s]).to be == uri.send(component)
+ end
+ end
+ end
+end
diff --git a/spec/email/template/filters_spec.rb b/spec/email/template/filters_spec.rb
new file mode 100644
index 0000000..a819376
--- /dev/null
+++ b/spec/email/template/filters_spec.rb
@@ -0,0 +1,45 @@
+require 'spec_helper'
+
+require 'tackle_box/email/template/filters'
+
+describe TackleBox::Email::Template::Filters do
+ describe "lowercase" do
+ subject { Liquid::Template.parse('{{ "INPUT" | lowercase }}') }
+
+ it "should downcase the input" do
+ expect(subject.render).to eq('input')
+ end
+ end
+
+ describe "uppercase" do
+ subject { Liquid::Template.parse('{{ "input" | uppercase }}') }
+
+ it "should upcase the input" do
+ expect(subject.render).to eq('INPUT')
+ end
+ end
+
+ describe "capitalize" do
+ subject { Liquid::Template.parse('{{ "input" | capitalize }}') }
+
+ it "should capitalize the input" do
+ expect(subject.render).to eq('Input')
+ end
+ end
+
+ describe "pluralize" do
+ subject { Liquid::Template.parse('{{ "input" | pluralize }}') }
+
+ it "should pluralize the input" do
+ expect(subject.render).to eq('inputs')
+ end
+ end
+
+ describe "singularize" do
+ subject { Liquid::Template.parse('{{ "inputs" | singularize }}') }
+
+ it "should singularize the input" do
+ expect(subject.render).to eq('input')
+ end
+ end
+end
diff --git a/spec/email/template/tags/attachment_spec.rb b/spec/email/template/tags/attachment_spec.rb
new file mode 100644
index 0000000..2823a3f
--- /dev/null
+++ b/spec/email/template/tags/attachment_spec.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+
+require 'tackle_box/email/template/tags/attachment'
+
+describe TackleBox::Email::Template::Tags::Attachment do
+ subject { Liquid::Template.parse('{% attachment %}') }
+
+ let(:attachment) { 'foo.zip' }
+
+ context "when 'attachment' is set" do
+ let(:context) do
+ Liquid::Context.new({'attachment' => {'name' => attachment}})
+ end
+
+ it "should add a random greeting" do
+ expect(subject.render(context)).to eq(attachment)
+ end
+
+ context "when 'attachment_password' is also set" do
+ let(:attachment_password) { 'secret' }
+ let(:context) do
+ Liquid::Context.new({
+ 'attachment' => {
+ 'name' => attachment,
+ 'password' => attachment_password
+ }
+ })
+ end
+
+ it "should add a random greeting" do
+ expect(subject.render(context)).to match(/^#{attachment} \(#{Regexp.union(described_class::TEXT)} #{attachment_password}\)$/)
+ end
+ end
+ end
+
+ context "when 'attachment' is not set" do
+ let(:context) { Liquid::Context.new }
+
+ it "should add a random greeting" do
+ expect(subject.render(context)).to eq('')
+ end
+ end
+end
diff --git a/spec/email/template/tags/date_spec.rb b/spec/email/template/tags/date_spec.rb
new file mode 100644
index 0000000..28eaf16
--- /dev/null
+++ b/spec/email/template/tags/date_spec.rb
@@ -0,0 +1,11 @@
+require 'spec_helper'
+
+require 'tackle_box/email/template/tags/date'
+
+describe TackleBox::Email::Template::Tags::Date do
+ subject { Liquid::Template.parse('{% date %}') }
+
+ it "should render the current date" do
+ expect(subject.render).to eq(Date.today.strftime("%b %e"))
+ end
+end
diff --git a/spec/email/template/tags/day_spec.rb b/spec/email/template/tags/day_spec.rb
new file mode 100644
index 0000000..49890f2
--- /dev/null
+++ b/spec/email/template/tags/day_spec.rb
@@ -0,0 +1,11 @@
+require 'spec_helper'
+
+require 'tackle_box/email/template/tags/day'
+
+describe TackleBox::Email::Template::Tags::Day do
+ subject { Liquid::Template.parse('{% day %}') }
+
+ it "should render the current day" do
+ expect(subject.render).to eq("#{Date.today.day}")
+ end
+end
diff --git a/spec/email/template/tags/link_spec.rb b/spec/email/template/tags/link_spec.rb
new file mode 100644
index 0000000..93412cf
--- /dev/null
+++ b/spec/email/template/tags/link_spec.rb
@@ -0,0 +1,66 @@
+require 'spec_helper'
+
+require 'tackle_box/email/template/tags/link'
+
+describe TackleBox::Email::Template::Tags::Link do
+ let(:template) { Liquid::Template.parse('{% link %}') }
+
+ let(:link) { 'http://apt.trailofbits.com/uas/request-password-reset?trk=signin_fpwd' }
+ let(:url) { URI(link) }
+
+ let(:param) { described_class::TOKEN_PARAM }
+ let(:token) { 'jkfsjls' }
+
+ context "when 'link' is set" do
+ let(:context) do
+ Liquid::Context.new({
+ 'token' => token,
+ 'link' => {
+ 'scheme' => url.scheme,
+ 'host' => url.host,
+ 'port' => url.port,
+ 'path' => url.path,
+ 'query' => url.query
+ }
+ })
+ end
+
+ subject { template.render(context) }
+
+ it "should add the original link" do
+ expect(subject).to include(link)
+ end
+
+ context "when the link has existing query params" do
+ it "should append the tracking query parameter and recipient token" do
+ expect(subject).to include("{param}=#{token}")
+ end
+ end
+
+ context "when the link does not have query params" do
+ before { url.query = nil }
+
+ it "should set the tracking query parameter and recipient token" do
+ expect(subject).to include("?#{param}=#{token}")
+ end
+ end
+
+ context "when link_shortener is set" do
+ before { context['link_shortener'] = 'tinyurl' }
+
+ it "should shorten the resulting link" do
+ expect(subject).to include("http://tinyurl.com/")
+ end
+ end
+ end
+
+ context "when 'link' is not set" do
+ let(:context) { Liquid::Context.new }
+
+ subject { template.render(context) }
+
+ it "should add a random greeting" do
+ expect(subject).to be == ''
+ end
+ end
+end
diff --git a/spec/email/template/tags/random_greeting_spec.rb b/spec/email/template/tags/random_greeting_spec.rb
new file mode 100644
index 0000000..c9505dd
--- /dev/null
+++ b/spec/email/template/tags/random_greeting_spec.rb
@@ -0,0 +1,11 @@
+require 'spec_helper'
+
+require 'tackle_box/email/template/tags/random_greeting'
+
+describe TackleBox::Email::Template::Tags::RandomGreeting do
+ subject { Liquid::Template.parse('{% random_greeting %}') }
+
+ it "should add a random greeting" do
+ expect(subject.render).not_to be_empty
+ end
+end
diff --git a/spec/email/template/tags/week_day_spec.rb b/spec/email/template/tags/week_day_spec.rb
new file mode 100644
index 0000000..c7f93c7
--- /dev/null
+++ b/spec/email/template/tags/week_day_spec.rb
@@ -0,0 +1,12 @@
+require 'spec_helper'
+require 'tackle_box/email/template/tags/week_day'
+
+describe TackleBox::Email::Template::Tags::WeekDay do
+ subject { Liquid::Template.parse('{% week_day %}') }
+
+ let(:week_days) { %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday] }
+
+ it "should render the current week day" do
+ expect(subject.render).to eq(week_days[Date.today.wday])
+ end
+end
diff --git a/spec/email/template/tags/year_spec.rb b/spec/email/template/tags/year_spec.rb
new file mode 100644
index 0000000..6081124
--- /dev/null
+++ b/spec/email/template/tags/year_spec.rb
@@ -0,0 +1,11 @@
+require 'spec_helper'
+
+require 'tackle_box/email/template/tags/year'
+
+describe TackleBox::Email::Template::Tags::Year do
+ subject { Liquid::Template.parse('{% year %}') }
+
+ it "should render the current year" do
+ expect(subject.render).to eq("#{Date.today.year}")
+ end
+end
diff --git a/spec/email/template_spec.rb b/spec/email/template_spec.rb
new file mode 100644
index 0000000..ff02e8f
--- /dev/null
+++ b/spec/email/template_spec.rb
@@ -0,0 +1,125 @@
+require 'spec_helper'
+
+require 'tackle_box/email/template'
+
+describe TackleBox::Email::Template do
+ let(:file) { File.join(File.dirname(__FILE__),'email_template.eml') }
+
+ describe ".open" do
+ subject { described_class.open(file) }
+
+ let(:contents) { File.read(file) }
+
+ it "should load the contents of the file" do
+ expect(subject.email).to be == contents
+ end
+ end
+
+ subject { described_class.open(file) }
+
+ describe "#requires_to?" do
+ it "should check for '{{ to.'" do
+ expect(subject.email).to receive(:include?).with('{{ to.').and_return(true)
+
+ subject.requires_to?
+ end
+ end
+
+ describe "#requires_from?" do
+ it "should check for '{{ from.'" do
+ expect(subject.email).to receive(:include?).with('{{ from.').and_return(true)
+
+ subject.requires_from?
+ end
+ end
+
+ describe "#requires_link?" do
+ it "should check for '{% link %}' first" do
+ expect(subject.email).to receive(:include?).with('{% link %}').and_return(true)
+
+ subject.requires_link?
+ end
+ end
+
+ describe "#requires_attachment?" do
+ it "should check for '{% attachment %}' first" do
+ expect(subject.email).to receive(:include?).with('{% attachment %}').and_return(false)
+ expect(subject.email).to receive(:include?).with('{{ attachment.').and_return(false)
+
+ subject.requires_attachment?
+ end
+ end
+
+ describe "#render" do
+ subject { super().render(variables) }
+
+ let(:name) { "Dan Guido" }
+ let(:token) { 'jfsjlfsjl' }
+ let(:link) { 'http://lurkdin.com/uas/request-password-reset?trk=signin_fpwd' }
+
+ let(:variables) do
+ {
+ 'to' => {
+ 'name' => name,
+ 'address' => 'dan@trailofbits.com'
+ },
+ 'from' => {
+ 'name' => 'Nick De',
+ 'address' => 'nick@trailofberts.com'
+ },
+ 'token' => token,
+ 'link' => URI(link)
+ }
+ end
+
+ let(:expected_html) do
+ %{
+
+
+
+ test email
+
+
+ Checkout these nudes.
+
+
+ }.strip << "\n"
+ end
+
+ let(:expected_to) do
+ "#{variables['to']['name']} <#{variables['to']['address']}>"
+ end
+
+ let(:expected_from) do
+ "#{variables['from']['name']} <#{variables['from']['address']}>"
+ end
+
+ let(:expected_subject) { "Hello, #{name}" }
+
+ it "should render the headers" do
+ expect(subject.header[:to].value).to be == expected_to
+ expect(subject.header[:from].value).to be == expected_from
+ expect(subject.header[:subject].value).to be == expected_subject
+ end
+
+ it "should render the body" do
+ expect(subject.body.decoded).to be == expected_html
+ end
+
+ context "when the email template is multi-part" do
+ let(:file) { File.join(File.dirname(__FILE__),'multipart_email_template.eml') }
+
+ let(:expected_txt) do
+ %{Checkout these nudes (#{link}&token=#{token}).}
+ end
+
+ it "should render any text/plain body parts" do
+ expect(subject.body.parts[0].decoded).to be == expected_txt
+ end
+
+ it "should render any text/html body parts" do
+ expect(subject.body.parts[1].decoded).to be == expected_html
+ end
+ end
+ end
+end
diff --git a/spec/link_spec.rb b/spec/link_spec.rb
new file mode 100644
index 0000000..4919717
--- /dev/null
+++ b/spec/link_spec.rb
@@ -0,0 +1,53 @@
+require 'spec_helper'
+require 'tackle_box/link'
+
+describe TackleBox::Link do
+ let(:scheme) { 'http' }
+ let(:host) { "www.linkedin.com" }
+ let(:port) { 80 }
+ let(:path) { "/uas/request-password-reset" }
+ let(:query) { "trk=signin_fpwd" }
+ let(:url) { URI::HTTP.build([nil, host, port, path, query, nil]) }
+
+ subject { described_class.new(url) }
+
+ describe "#domain" do
+ it "should return the host from the link" do
+ expect(subject.domain).to be == host
+ end
+ end
+
+ describe "#to_liquid" do
+ subject { super().to_liquid }
+
+ it "should contain the full url" do
+ expect(subject['url']).to be == url.to_s
+ end
+
+ it "should contain a scheme" do
+ expect(subject['scheme']).to be == scheme
+ end
+
+ it "should contain a host" do
+ expect(subject['host']).to be == host
+ end
+
+ it "should contain a port" do
+ expect(subject['port']).to be == port
+ end
+
+ it "should contain a path" do
+ expect(subject['path']).to be == path
+ end
+
+ it "should contain a query" do
+ expect(subject['query']).to be == query
+ end
+ end
+
+ describe "#to_s" do
+ it "should return the full link" do
+ expect(subject.to_s).to be == url.to_s
+ end
+ end
+end
diff --git a/spec/organization_spec.rb b/spec/organization_spec.rb
new file mode 100644
index 0000000..a4a757b
--- /dev/null
+++ b/spec/organization_spec.rb
@@ -0,0 +1,109 @@
+require 'spec_helper'
+
+require 'tackle_box/organization'
+
+describe TackleBox::Organization do
+ let(:name) { 'Trail of Bits, LLC' }
+ let(:domain) { 'trailofbits.com' }
+ let(:website) { 'http://www.trailofbits.com/' }
+ let(:industry) { 'Information Security' }
+
+ subject do
+ described_class.new(name, domain: domain,
+ website: website,
+ industry: industry)
+ end
+
+ describe "#initialize" do
+ subject { described_class.new(name) }
+
+ it "should set name" do
+ expect(subject.name).to be name
+ end
+
+ context "when domain is given" do
+ subject do
+ described_class.new(name, domain: domain)
+ end
+
+ it "should set domain" do
+ expect(subject.domain).to be domain
+ end
+ end
+
+ context "when website is given" do
+ subject do
+ described_class.new(name, website: website)
+ end
+
+ it "should set website" do
+ expect(subject.website).to be website
+ end
+ end
+
+ context "when website is omitted" do
+ context "when domain is given" do
+ subject do
+ described_class.new(name, domain: domain)
+ end
+
+ it "should derive the website from the domain" do
+ expect(subject.website).to be == "http://#{domain}"
+ end
+ end
+
+ context "when domain is also omitted" do
+ it "should be nil" do
+ expect(subject.website).to be_nil
+ end
+ end
+ end
+ end
+
+ describe "#person" do
+ let(:address) { 'dan@trailofbits.com' }
+ let(:name) { 'Dan Guido' }
+ let(:title) { 'CEO' }
+
+ before do
+ subject.person(address, name: name, title: title)
+ end
+
+ let(:person) { subject.people.last }
+
+ it "should add a person to the organization" do
+ expect(person).to_not be_nil
+ expect(person.address).to be == address
+ expect(person.name).to be == name
+ expect(person.title).to be == title
+ end
+ end
+
+ describe "#to_liquid" do
+ subject { super().to_liquid }
+
+ it "should have a 'name'" do
+ expect(subject['name']).to be == name
+ end
+
+ it "should have a 'domain'" do
+ expect(subject['domain']).to be == domain
+ end
+
+ it "should have an 'industry'" do
+ expect(subject['industry']).to be == industry
+ end
+
+ it "should have a 'website'" do
+ expect(subject['website']).to be == website
+ end
+ end
+
+ describe "#to_s" do
+ subject { super().to_s }
+
+ it "should return the name" do
+ expect(subject).to be == name
+ end
+ end
+end
diff --git a/spec/person_spec.rb b/spec/person_spec.rb
new file mode 100644
index 0000000..94912fb
--- /dev/null
+++ b/spec/person_spec.rb
@@ -0,0 +1,110 @@
+require 'spec_helper'
+
+require 'tackle_box/person'
+
+describe TackleBox::Person do
+ let(:name) { "Dan Guido" }
+ let(:address) { "dan@trailofbits.com" }
+ let(:title) { "CEO" }
+ let(:department) { "Management" }
+ let(:location) { 'New York, NY' }
+
+ subject do
+ described_class.new(address, name: name,
+ title: title,
+ department: department,
+ location: location)
+ end
+
+ describe "#initialize" do
+ subject do
+ described_class.new(address, name: name)
+ end
+
+ it "should set address" do
+ expect(subject.address).to be address
+ end
+
+ context "when name is given" do
+ subject do
+ described_class.new(address,name: name)
+ end
+
+ it "should set name" do
+ expect(subject.name).to be name
+ end
+ end
+
+ context "when title is given" do
+ subject do
+ described_class.new(address,title: title)
+ end
+
+ it "should set title" do
+ expect(subject.title).to be title
+ end
+ end
+
+ context "when department is given" do
+ subject do
+ described_class.new(address,department: department)
+ end
+
+ it "should set department" do
+ expect(subject.department).to be department
+ end
+ end
+
+ context "when location is given" do
+ subject do
+ described_class.new(address,location: location)
+ end
+
+ it "should set location" do
+ expect(subject.location).to be location
+ end
+ end
+ end
+
+ describe "#to_liquid" do
+ subject { super().to_liquid }
+
+ it "should include name" do
+ expect(subject['name']).to be == name
+ end
+
+ it "should include address" do
+ expect(subject['address']).to be == address
+ end
+
+ it "should include title" do
+ expect(subject['title']).to be == title
+ end
+
+ it "should include department" do
+ expect(subject['department']).to be == department
+ end
+
+ it "should include the location" do
+ expect(subject['location']).to be == location
+ end
+ end
+
+ describe "#to_s" do
+ context "when name is omitted" do
+ subject { described_class.new(address) }
+
+ it "should return the address" do
+ expect(subject.to_s).to be == address
+ end
+ end
+
+ context "when #name is set" do
+ subject { described_class.new(address, name: name) }
+
+ it "should == 'name '" do
+ expect(subject.to_s).to be == "#{name} <#{address}>"
+ end
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
new file mode 100644
index 0000000..f860f95
--- /dev/null
+++ b/spec/spec_helper.rb
@@ -0,0 +1,4 @@
+require 'rspec'
+require 'tackle_box/version'
+
+include TackleBox
diff --git a/spec/tackle_box_spec.rb b/spec/tackle_box_spec.rb
new file mode 100644
index 0000000..0d07891
--- /dev/null
+++ b/spec/tackle_box_spec.rb
@@ -0,0 +1,8 @@
+require 'spec_helper'
+require 'tackle_box'
+
+describe TackleBox do
+ it "should have a VERSION constant" do
+ expect(subject.const_get('VERSION')).not_to be_empty
+ end
+end
diff --git a/tackle_box.gemspec b/tackle_box.gemspec
new file mode 100644
index 0000000..2c48b5c
--- /dev/null
+++ b/tackle_box.gemspec
@@ -0,0 +1,28 @@
+# -*- encoding: utf-8 -*-
+
+require File.expand_path('../lib/tackle_box/version', __FILE__)
+
+Gem::Specification.new do |gem|
+ gem.name = "tackle_box"
+ gem.version = TackleBox::VERSION
+ gem.summary = %q{Phishing toolkit}
+ gem.description = %q{A phishing toolkit for generating and sending phishing emails}
+ gem.license = "MIT"
+ gem.authors = ["Hal Brodigan"]
+ gem.email = "hal@trailofbits.com"
+ gem.homepage = "https://github.com/trailofbits/tackle_box"
+
+ gem.files = `git ls-files`.split($/)
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
+ gem.require_paths = ['lib']
+
+ gem.add_dependency 'activesupport', '~> 3.2'
+ gem.add_dependency 'shorturl', '~> 1.0'
+ gem.add_dependency 'liquid', '~> 2.4'
+ gem.add_dependency 'nokogiri', '~> 1.6'
+ gem.add_dependency 'chars', '~> 0.2'
+ gem.add_dependency 'mail', '~> 2.5'
+
+ gem.add_development_dependency 'bundler', '~> 1.0'
+end