yegor256/mailanes

View on GitHub
objects/letter.rb

Summary

Maintainability
D
2 days
Test Coverage
# frozen_string_literal: true

# Copyright (c) 2018-2024 Yegor Bugayenko
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the 'Software'), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

require 'yaml'
require 'mail'
require 'uuidtools'
require 'timeout'
require 'pg'
require 'liquid'
require 'loog'
require 'redcarpet'
require 'redcarpet/render_strip'
require 'glogin/codec'
require_relative 'hex'
require_relative 'campaign'
require_relative 'user_error'
require_relative 'yaml_doc'

# Letter.
# Author:: Yegor Bugayenko (yegor256@gmail.com)
# Copyright:: Copyright (c) 2018-2024 Yegor Bugayenko
# License:: MIT
class Letter
  # When can't deliver
  class CantDeliver < StandardError; end

  attr_reader :id

  def initialize(id:, pgsql:, hash: {}, tbot: Tbot.new, log: Loog::NULL)
    raise "Invalid ID: #{id} (#{id.class.name})" unless id.is_a?(Integer)
    @id = id
    @pgsql = pgsql
    @hash = hash.dup
    @tbot = tbot
    @log = log
  end

  def lane
    id = @hash['lane'] || @pgsql.exec('SELECT lane FROM letter WHERE id=$1', [@id])[0]['lane']
    Lane.new(
      id: id.to_i,
      pgsql: @pgsql
    )
  end

  def campaigns
    q = [
      'SELECT * FROM campaign',
      'JOIN letter ON letter.lane=campaign.lane',
      'WHERE letter.id=$1'
    ]
    @pgsql.exec(q, [@id]).map do |r|
      Campaign.new(id: r['id'].to_i, pgsql: @pgsql, hash: r)
    end
  end

  def title
    yaml['title'] || "##{id}"
  end

  def deliveries_count
    @pgsql.exec('SELECT COUNT(*) FROM delivery WHERE letter=$1', [@id])[0]['count'].to_i
  end

  def opened_count
    @pgsql.exec('SELECT COUNT(*) FROM delivery WHERE letter=$1 AND opened != \'\'', [@id])[0]['count'].to_i
  end

  def bounce_count
    @pgsql.exec('SELECT COUNT(*) FROM delivery WHERE letter=$1 AND bounced IS NOT NULL', [@id])[0]['count'].to_i
  end

  def unsubscribe_count
    @pgsql.exec('SELECT COUNT(*) FROM delivery WHERE letter=$1 AND unsubscribed IS NOT NULL', [@id])[0]['count'].to_i
  end

  def exists?
    !@pgsql.exec('SELECT id FROM letter WHERE id=$1', [@id]).empty?
  end

  def active?
    (@hash['active'] || @pgsql.exec('SELECT active FROM letter WHERE id=$1', [@id])[0]['active']) == 't'
  end

  def liquid
    @hash['liquid'] || @pgsql.exec('SELECT liquid FROM letter WHERE id=$1', [@id])[0]['liquid']
  end

  def yaml
    YamlDoc.new(
      @hash['yaml'] || @pgsql.exec('SELECT yaml FROM letter WHERE id=$1', [@id])[0]['yaml']
    ).load
  end

  def toggle
    @pgsql.exec('UPDATE letter SET active=not(active) WHERE id=$1', [@id])
    @hash = {}
  end

  def place
    (@hash['place'] || @pgsql.exec('SELECT place FROM letter WHERE id=$1', [@id])[0]['place']).to_i
  end

  def move(inc = 1)
    raise "Invalid direction #{inc.inspect}" if inc != 1 && inc != -1
    @pgsql.transaction do |t|
      other = t.exec(
        [
          'SELECT id, place FROM letter',
          "WHERE place #{inc.positive? ? '>' : '<'} $1 AND lane = $2",
          "ORDER BY place #{inc.positive? ? 'ASC' : 'DESC'}"
        ],
        [place, lane.id]
      )[0]
      raise UserError, 'Can\'t move in this direction' if other.nil?
      mine = place
      t.exec('UPDATE letter SET place=$1 WHERE id = $2', [65_536, other['id'].to_i])
      t.exec('UPDATE letter SET place=$1 WHERE id = $2', [other['place'].to_i, @id])
      t.exec('UPDATE letter SET place=$1 WHERE id = $2', [mine, other['id'].to_i])
    end
    @hash = {}
  end

  def liquid=(liquid)
    @pgsql.exec('UPDATE letter SET liquid=$1 WHERE id=$2', [liquid, @id])
    @hash = {}
  end

  def yaml=(yaml)
    @pgsql.exec('UPDATE letter SET yaml=$1 WHERE id=$2', [YamlDoc.new(yaml).save, @id])
    yml = YamlDoc.new(yaml).load
    speed = yml['speed'] ? yml['speed'].to_i : 65_536
    @pgsql.exec('UPDATE letter SET speed=$1 WHERE id=$2', [speed, @id])
    @hash = {}
  end

  def deliver(recipient, codec = GLogin::Codec.new, delivery: nil)
    content = markdown(liquid, codec, recipient, delivery)
    if yaml['transport'].nil? || yaml['transport'].casecmp('smtp').zero?
      deliver_smtp(content, recipient, codec, delivery)
    elsif yaml['transport'].casecmp('telegram').zero?
      deliver_telegram(content)
    else
      raise "Unknown transport \"#{yaml['transport']}\" for the letter ##{@id}"
    end
  end

  def attach(name, file)
    body = File.binread(file)
    @pgsql.exec(
      'INSERT INTO attachment (letter, name, body) VALUES ($1, $2, $3)',
      [@id, name, { value: body, type: 0, format: 1 }]
    )
  end

  def detach(name)
    @pgsql.exec('DELETE FROM attachment WHERE letter = $1 AND name = $2', [@id, name])
  end

  def attachments
    @pgsql.exec('SELECT name FROM attachment WHERE letter = $1', [@id]).map do |r|
      r['name']
    end
  end

  def download(name, file)
    @pgsql.exec('SELECT body FROM attachment WHERE letter = $1 and name = $2', [@id, name], 1).map do |r|
      File.binwrite(file, r['body'])
    end
  end

  private

  def deliver_telegram(content)
    chat = cfg(nil, 'telegram', 'chat_id').to_i
    start = Time.now
    @tbot.post(chat, content)
    [
      "Sent #{content.length} chars in Markdown",
      "to Telegram chat ID ##{chat},",
      "in #{(Time.now - start).round(2)}s"
    ].join(' ')
  end

  def deliver_smtp(content, recipient, codec, delivery)
    html = with_utm(
      Redcarpet::Markdown.new(Redcarpet::Render::HTML).render(content),
      delivery
    )
    tracking = cfg('on', 'tracking') == true || cfg('on', 'tracking').downcase.strip == 'on'
    if tracking && !delivery.nil?
      html += [
        "<img src=\"https://www.mailanes.com/opened?token=#{CGI.escape(codec.encrypt(delivery.id.to_s))}\"",
        'alt="" width="1" height="1" border="0"',
        'style="height:1px !important; width:1px !important;',
        'border-width:0 !important; margin:0 !important; padding:0 !important;"/>'
      ].join(' ')
    end
    text = Redcarpet::Markdown.new(Redcarpet::Render::StripDown).render(content.gsub("<br/>\n", '<br/>'))
      .gsub("\n", "\n\n")
      .gsub('<br/>', "\n")
    name = "#{recipient.first.strip} #{recipient.last.strip}".strip
    to = recipient.email
    to = "#{name} <#{recipient.email}>" unless name.empty?
    to = cfg(to, 'to').strip
    from = cfg(nil, 'from').strip
    cc = cfg([], 'cc')
    bcc = cfg([], 'bcc')
    subject = cfg('', 'subject').strip
    if yaml['quote']
      quote = lane.letters.letter(yaml['quote'].to_i)
      appendix = markdown(quote.liquid, codec, recipient, delivery)
      time = Time.now - (60 * 60 * 24 * 7)
      html += [
        "On #{time.strftime('%a %b %e')} at #{time.strftime('%I:%M %p')} #{from} wrote:<br/>",
        '<blockquote style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex">',
        with_utm(
          Redcarpet::Markdown.new(Redcarpet::Render::HTML).render(appendix),
          delivery
        ),
        '</blockquote>'
      ].join
      text += with_utm(
        Redcarpet::Markdown.new(Redcarpet::Render::StripDown).render(appendix),
        delivery
      ).split("\n").map { |t| "> #{t}" }.join("\n")
      raise "There is no subject in the letter ##{quote.id}" unless quote.yaml['subject']
      subject = "Re: #{quote.yaml['subject'].strip}"
    end
    mail = Mail.new do
      from from
      to to
      cc.each { |a| cc a }
      bcc.each { |a| bcc a }
      subject subject
      message_id "<#{UUIDTools::UUID.random_create}@mailanes.com>"
      text_part do
        content_type 'text/plain; charset=UTF-8'
        body text
      end
      html_part do
        content_type 'text/html; charset=UTF-8'
        body html
      end
    end
    bounces = cfg('reply@mailanes.com', 'bounces')
    unless delivery.nil?
      rid = recipient.id
      did = delivery.id
      mail.header['X-Mailanes-Recipient'] = "#{rid}:#{Hex::FromText.new(codec.encrypt(rid.to_s))}:#{did}"
      mail.header['List-Unsubscribe'] = [
        '<https://www.mailanes.com/unsubscribe?',
        unsubscribe(codec, recipient, delivery),
        ">, <mailto:#{bounces}?subject=",
        CGI.escape("MAILANES:#{rid}:#{codec.encrypt(rid.to_s)}:#{did}"),
        '>'
      ].join
      mail.header['List-Id'] = did.to_s
    end
    mail.header['Return-Path'] = bounces
    mail.header['X-Complaints-To'] = bounces
    attachments.each do |a|
      Tempfile.open do |f|
        download(a, f.path)
        mail.add_file(filename: a, content: File.read(f.path))
      end
    end
    mail.delivery_method(
      :smtp,
      address: cfg(nil, 'smtp', 'host'),
      port: cfg(25, 'smtp', 'port').to_i,
      domain: cfg(nil, 'smtp', 'host'),
      user_name: cfg(nil, 'smtp', 'user'),
      password: cfg(nil, 'smtp', 'password'),
      authentication: 'plain',
      enable_starttls_auto: true
    )
    start = Time.now
    Timeout.timeout(15) do
      mail.deliver
      @log.debug("Letter ##{@id} SMTP-sent to #{to} from \"#{recipient.list.title}\" \
in #{format('%.02f', Time.now - start)}")
    rescue Net::SMTPAuthenticationError => e
      raise CantDeliver, e.message
    end
    unless delivery.nil?
      decoy = delivery.campaign.decoy
      total = decoy['amount']
      total = (rand((1 / total).round).zero? ? 1 : 0) if total < 1 && !total.zero?
      total.times do
        fake = mail.dup
        fake.without_attachments!
        fake.to = decoy['address'].gsub('*') { rand(9).to_s }
        fake.cc = nil
        fake.bcc = nil
        fake.html_part = nil
        fake.header['X-Mailanes-Recipient'] = nil
        fake.header['List-Id'] = nil
        fake.header['List-Unsubscribe'] = nil
        fake.header['List-Unsubscribe'] = 'https://www.mailanes.com/'
        Timeout.timeout(15) do
          fake.deliver
        end
        @log.debug("Fake letter SMTP-sent to #{fake.to} from \"#{recipient.list.title}\"")
      end
    end
    [
      "Sent #{html.length} chars in HTML (#{text.length} in plain text)",
      "to #{to} (recipient ##{recipient.id} in list ##{recipient.list.id})",
      "from #{from}, with the subject line \"#{subject}\" via SMTP,",
      "in #{(Time.now - start).round(2)}s"
    ].join(' ')
  rescue Timeout::Error => e
    raise CantDeliver, e.message
  end

  def unsubscribe(codec, recipient, delivery)
    token = codec.encrypt(recipient.id.to_s)
    "token=#{CGI.escape(token)}" + (delivery.nil? ? '' : "&did=#{delivery.id}")
  end

  def markdown(lqd, codec, recipient, delivery)
    template = Liquid::Template.parse(lqd)
    query = unsubscribe(codec, recipient, delivery)
    template.render(
      'id' => recipient.id,
      'list_id' => recipient.list.id,
      'email' => recipient.email,
      'first' => recipient.first.empty? ? nil : recipient.first,
      'last' => recipient.last.empty? ? nil : recipient.last,
      'unsubscribe_query' => query,
      'unsubscribe' => "https://www.mailanes.com/unsubscribe?#{query}",
      'confirm' => "https://www.mailanes.com/confirm?#{query}",
      'profile' => "https://www.mailanes.com/recipient?id=#{recipient.id}"
    )
  end

  def with_utm(text, delivery)
    text.gsub(%r{(https?://[a-zA-Z0-9%-_./&?]+)}) do |u|
      r = u + (u.include?('?') ? '&' : '?')
      r += 'utm_source=mailanes.com&utm_medium=email&utm_campaign='
      r += delivery.nil? ? '0' : delivery.campaign.id.to_s
      r
    end
  end

  def cfg(default, *items)
    result = yaml
    items.each do |i|
      result = result[i]
      break if result.nil?
    end
    if result.nil?
      result = lane.yaml
      items.each do |i|
        result = result[i]
        break if result.nil?
      end
    end
    if result.nil?
      if default.nil?
        raise UserError, "\"#{items.join('/')}\" not found in the YAML config \
of the letter ##{id} and of the lane ##{lane.id}; make sure they are confired as explained here: \
https://github.com/yegor256/mailanes"
      end
      result = default
    end
    result
  end
end