module SimpleMailingList::System

Constants

DEFAULT_CONFIGFILE

Private Instance Methods

_add_user(address, user_options = {}) click to toggle source
# File lib/simple_mailing_list/main.rb, line 283
def _add_user(address, user_options = {})
  user = User.new
  user.mail_address = address
  user.options      = JSON.generate(user_options)
  user.save!
  @log.info "Add user[#{address}]."
end
_check_mail_file(filename) click to toggle source
# File lib/simple_mailing_list/main.rb, line 25
def _check_mail_file(filename)
  mail = Mail.read(filename)
  begin
    return if  register_mail(mail, filename)
    return if    delete_mail(mail, filename)
    return if   confirm_mail(mail, filename)
    return if   bounced_mail(mail, filename)
    return if   forward_mail(mail, filename)
    return if unmatched_mail(mail, filename)
  rescue => e
    move_mail_file(filename, "error")
    @log.error "Error Mail[#{File.basename(filename)}]!\n  #{error_message(e)}"
  end
end
_check_mails(thread_join = true) click to toggle source
# File lib/simple_mailing_list/main.rb, line 16
def _check_mails(thread_join = true)
  threads = receive_mails.map do |mail_filename|
    Thread.start(File.join(@maillogs_dir, "temp", mail_filename)) do |filename|
      _check_mail_file(filename)
    end
  end
  threads.each { |thread| thread.join } if thread_join
end
_cleanup(delete_maillogs = false) click to toggle source
# File lib/simple_mailing_list/setup.rb, line 47
def _cleanup(delete_maillogs = false)
  ActiveRecord::Base.transaction do |a|
    ActiveRecord::Migration.drop_table(:users)
    ActiveRecord::Migration.drop_table(:confirmations)

    FileUtils.remove_entry_secure(@maillogs_dir, true) if delete_maillogs
  end
  @log.info "Cleanup successed."
end
_delete_old_confirmations() click to toggle source
# File lib/simple_mailing_list/delete_old.rb, line 26
def _delete_old_confirmations()
  @log.debug "Delete old confirmations."
  time = Time.now - @validity_time
  confirmations = Confirmation.where("created_at < ?", time)
  num = confirmations.size
  confirmations.destroy_all()
  @log.info "#{num} confirmation#{num > 1 ? 's' : ''} #{num > 1 ? 'were' : 'was'} deleted." if num > 0
end
_delete_old_maillogs() click to toggle source
# File lib/simple_mailing_list/delete_old.rb, line 35
def _delete_old_maillogs()
  return unless @maillogs_period >= 0

  @log.debug "Delete old maillogs."
  time = Time.now - @maillogs_period
  num = 0
  maillogs = Dir.glob(File.join(@maillogs_dir, "*", "*.eml")).select do |maillog|
    File.mtime(maillog) < time
  end
  num = maillogs.size
  maillogs.each { |maillog| File.delete(maillog) }
  @log.info "#{num} maillog#{num > 1 ? 's' : ''} #{num > 1 ? 'were' : 'was'} deleted." if num > 0
end
_delete_user(address) click to toggle source
# File lib/simple_mailing_list/main.rb, line 291
def _delete_user(address)
  User.where(mail_address: address).destroy_all()
  @log.info "Delete user[#{address}]."
end
_disable_failed_users(failed_count = 10, time = 5 * 24 * 60 * 60, reset = true) click to toggle source
# File lib/simple_mailing_list/delete_old.rb, line 8
def _disable_failed_users(failed_count = 10, time = 5 * 24 * 60 * 60, reset = true)
  @log.debug "Delete failed users."
  last_failed_at = Time.now - time
  users = User.where("last_failed_at > ? AND failed_count > ?", last_failed_at, failed_count)
  users.each do |user|
    next if user.enabled == 0
    @log.info "disable user[#{user.mail_address}]"
    user.enabled = 0
    user.save
  end
  if reset
    User.where(enabled: 1).each do |user|
      user.failed_count = 0
      user.save
    end
  end
end
_setup() click to toggle source
# File lib/simple_mailing_list/setup.rb, line 9
def _setup()
  ActiveRecord::Base.transaction do |a|
    ActiveRecord::Migration.create_table :users do |t|
      t.text      :mail_address   , null: false
      t.integer   :enabled        , null: false, default: 1
      t.integer   :failed_count   , null: false, default: 0
      t.timestamp :last_failed_at , null: false, default: Time.at(0)
      t.text      :options        , null: false, default: "{}"
      t.timestamps                  null: false
    end

    ActiveRecord::Migration.create_table :confirmations do |t|
      t.text     :mail_address , null: false
      t.text     :check_code   , null: false
      t.text     :mode         , null: false
      t.text     :options      , null: false, default: "{}"
      t.timestamps               null: false
    end

    FileUtils.makedirs(
      %w[
        temp
        register
        delete
        register_check
        delete_check
        forward
        bounced
        unmatched
        error
      ].map do |dir|
        File.join(@maillogs_dir, dir)
      end
    )
  end
  @log.info "Setup successed."
end
bounced_mail(mail, filename) click to toggle source
# File lib/simple_mailing_list/main.rb, line 150
def bounced_mail(mail, filename)
  unless mail.bounced?
    return false
  end

  matched = mail.final_recipient.to_s.match(/;\s*([^@]+@[^@]+)/)
  if matched
    mail_address = matched[1]
    @log.warn "Bounced mail[#{File.basename(filename)}] from #{mail_address}."
    User.where(mail_address: mail_address).each do |user|
      user.failed_count += 1
      user.last_failed_at = Time.now
      user.save
    end
  else
    @log.warn "Bounced mail[#{File.basename(filename)}]."
  end
  move_mail_file(filename, "bounced")
  return true
end
confirm_mail(mail, filename) click to toggle source
# File lib/simple_mailing_list/main.rb, line 107
def confirm_mail(mail, filename)
  address = Array(mail.from).first.to_s
  return false if address.empty?
  subject = mail.subject.to_s
  body = mail.body ? mail.body.decoded : ""
  body += mail.text_part.decoded.to_s if mail.text_part
  
  Confirmation.where(mail_address: address).each do |confirmation|
    check_code = confirmation.check_code
    next unless subject.index(check_code) || body.index(check_code)
    
    confirm_options = {}
    subject_text, body_text = case confirmation.mode
    when "register"
      @log.info "New register check mail from #{address}."
      confirm_options = JSON.parse(confirmation.options)
      _add_user(address, confirm_options)
      [@register_success_subject, @register_success_body]
    when "delete"
      @log.info "New delete check mail from #{address}."
      _delete_user(address)
      [@delete_success_subject, @delete_success_body]
    else
      next
    end
    
    create_mail(
      to:       address,
      subject:  subject_text,
      body:     body_text,
      reply_to: @reply_to_address,
      options:  confirm_options
    ).deliver!
    sleep @sleep_time1

    move_mail_file(filename, "#{confirmation.mode}_check")
    confirmation.destroy
    return true
  end
  
  return false
end
create_check_code() click to toggle source
# File lib/simple_mailing_list/main.rb, line 403
def create_check_code()
  return SecureRandom.base64(9)
end
create_forward_mail(mail, subject) click to toggle source
# File lib/simple_mailing_list/main.rb, line 332
def create_forward_mail(mail, subject)
  from = @use_address_camouflage ? Array(mail.from).first.to_s : nil
  new_mail = create_mail(
    from: from,
    subject: subject,
    reply_to: @use_address_camouflage ? nil : @reply_to_address
  )
  new_mail.in_reply_to = mail.in_reply_to if mail.in_reply_to
  new_mail.references  = mail.references  if mail.references

  if (@enable_html_mail || !mail.html_part)
    new_mail.content_type = mail.content_type
    new_mail.content_transfer_encoding = mail.content_transfer_encoding
    mail_head = new_mail.to_s.sub(/\r\n\r\n.*\z/m, "\r\n\r\n")
    mail_body = mail.raw_source.gsub(/(\r\n|\n|\r)/, "\r\n").sub(/\A.*?\r\n\r\n/m, "")
    return Mail.read_from_string(mail_head + mail_body)
  end

  body_text = mail.body ? mail.body.decoded.to_s : ""
  if mail.charset && !mail.charset.match(/utf-?8/i) && !mail.text_part
    body_text.force_encoding(mail.charset)
    body_text.encode!("utf-8")
  end
  body_text = mail.text_part.decoded.to_s if mail.text_part
  html_text = mail.html_part && mail.html_part.decoded.to_s
  new_mail.text_part = Mail::Part.new { body body_text }
  new_mail.html_part = Mail::Part.new { body html_text } if @enable_html_mail && html_text
  if mail.has_attachments?
    mail.attachments.each do |attachment|
      attachment_filename = attachment.filename.to_s
      if File.extname(attachment_filename).empty?
        ext = case attachment.mime_type.to_s.downcase
        when "text/plain"
          ".txt"
        when "application/pdf"
          ".pdf"
        when "application/rtf"
          ".rtf"
        when "application/msword"
          ".doc"
        when "application/vnd.ms-excel"
          ".xls"
        when "application/vnd.ms-powerpoint"
          ".ppt"
        when "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
          ".docx"
        when "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
          ".xlsx"
        when "application/vnd.openxmlformats-officedocument.presentationml.presentation"
          ".pptx"
        when "application/x-js-taro"
          ".jtd"
        when "image/gif"
          ".gif"
        when "image/jpeg"
          ".jpeg"
        when "image/png"
          ".png"
        when "application/x-zip-compressed", "application/zip"
          ".zip"
        else
          ""
        end
        attachment_filename += ext
      end
      new_mail.attachments[attachment_filename] = attachment.decoded
    end
  end
  return new_mail
end
create_mail(from: nil, to: nil, subject: "", body: nil, reply_to: nil, options: {}) click to toggle source
# File lib/simple_mailing_list/main.rb, line 320
def create_mail(from: nil, to: nil, subject: "", body: nil, reply_to: nil, options: {})
  mail = Mail.new
  mail.charset = @deliver_server["charset"] || "utf-8"
  from = @deliver_server["address"] unless from
  mail.from = options["from"] = from if from
  mail.to   = options["to"  ] = to   if to
  mail.reply_to = reply_to if reply_to
  mail.subject = render_text(subject, options)
  mail.body    = render_text(body   , options) if body
  return mail
end
delete_mail(mail, filename) click to toggle source
# File lib/simple_mailing_list/main.rb, line 79
def delete_mail(mail, filename)
  rule = find_rule(@delete, mail)
  address = Array(mail.from).first.to_s
  return false if !rule || address.empty?
  @log.info "New delete mail from #{address}."
  return true if Confirmation.where(mail_address: address).size >= @max_check_times

  check_code = create_check_code()
  Confirmation.new(
    mail_address: address,
    check_code:   check_code,
    mode:         "delete",
    options:      "{}"
  ).save!
  
  mail = create_mail(
    to:      address,
    subject: @delete_confirm_subject,
    body:    @delete_confirm_body,
    options: { "checkcode" => check_code }
  )
  mail.subject += check_code unless mail.subject.index(check_code)
  mail.deliver!
  sleep @sleep_time1
  move_mail_file(filename, "delete")
  return true
end
error_message(error) click to toggle source
# File lib/simple_mailing_list/main.rb, line 421
def error_message(error)
  "#{error.inspect} #{error.backtrace.first}"
end
find_rule(rules, mail) click to toggle source
# File lib/simple_mailing_list/main.rb, line 40
def find_rule(rules, mail)
  mail_body = mail.body ? mail.body.decoded.to_s : ""
  mail_body += mail.text_part.decoded.to_s if mail.text_part
  return rules.find do |rule|
    (!rule["address"] || Array(mail.to).index(rule["address"])) &&
    (!rule["subject"] || mail.subject.to_s.index(rule["subject"])) &&
    (!rule["body"   ] || mail_body.index(rule["body"]))
  end
end
forward_mail(mail, filename) click to toggle source
# File lib/simple_mailing_list/main.rb, line 171
def forward_mail(mail, filename)
  rule = find_rule(@forward, mail)
  address = Array(mail.from).first.to_s
  return false if !rule || address.empty?
  forward_options = rule["options"] || {}
  subject = mail.subject.to_s
  @log.info "New forward mail from #{address}."
  if @permitted_users
    permitted_user = @permitted_users.find do |user|
      (!user["address"]    || user["address"] == address) &&
      (!user["check_code"] || subject.encode("utf-8").index(user["check_code"]))
    end
    return forward_mail_failed(mail, filename) unless permitted_user
    if permitted_user["check_code"]
      subject = subject.encode("utf-8").sub(permitted_user["check_code"], "")
    end
  elsif @registered_user_only
    return forward_mail_failed(mail, filename) unless User.find_by(mail_address: address)
  end
  danger_ext = /\.(exe|com|bat|cmd|vbs|vbe|js|jse|wsf|wsh|msc|jar|hta|scr|cpl|lnk)$/i
  if mail.has_attachments?
    attachment = mail.attachments.to_a.find do |attachment|
      attachment.filename.to_s.match(danger_ext)
    end
    if attachment
      @log.warn("Contains danger attachment![#{attachment}@#{File.basename(filename)}]")
      return forward_mail_failed(mail, filename)
    end
  end
  
  sendmail = create_forward_mail(mail, subject)
  
  users = User.where(enabled: 1).to_a.select do |user|
    user_options = JSON.parse(user.options)
    !forward_options.keys.any?{ |key| forward_options[key] != user_options[key] }
  end
  users.map! { |user| user.mail_address }
  users.unshift(address)
  users.uniq!
  
  @log.info "Sending start."
  domains = { "???" => [] }
  users.each do |user|
    domain = user.match(/@([^@]+)$/) ? $1 : "???"
    domains[domain] ||= []
    domains[domain].push(user)
  end
  max = domains.values.map(&:size).max
  max.times do |i|
    time = Time.now
    domains.each_value do |address_array|
      mail_address = address_array[i]
      next unless mail_address
      
      @log.debug "Send to #{mail_address}"
      sendmail.to = mail_address
      begin
        sendmail.deliver!
      rescue => e
        @log.warn "Sending Error!(#{mail_address})\n  #{error_message(e)}"
        User.where(mail_address: mail_address).each do |user|
          user.failed_count += 1
          user.last_failed_at = Time.now
          user.save
        end
      end
      sleep @sleep_time1
    end
    next if Time.now - time > @sleep_time2 || i+1 == max
    sleep time - Time.now + @sleep_time2
  end
  
  @log.info "Forward mails to #{users.size} user#{users.size > 0 ? 's' : ''}."
  if @forward_success_subject && @forward_success_body
    options = {
      "subject" => subject.encode("utf-8"),
      "count"   => users.size
    }
    reportmail = create_mail(
      to:      address,
      subject: @forward_success_subject,
      body:    @forward_success_body,
      options: options
    ).deliver
  end
  move_mail_file(filename, "forward")
  return true
end
forward_mail_failed(mail, filename) click to toggle source
# File lib/simple_mailing_list/main.rb, line 260
def forward_mail_failed(mail, filename)
  return true unless @forward_fail_subject && @forward_fail_body
  
  options = {
    "subject" => mail.subject.to_s.encode("utf-8")
  }
  sendmail = create_mail(
    to:      Array(mail.from).first.to_s,
    subject: @forward_fail_subject,
    body:    @forward_fail_body,
    options: options
  ).deliver
  move_mail_file(filename, "forward")
  return true
end
load_configfile(configfile) click to toggle source
# File lib/simple_mailing_list/configfile.rb, line 7
def load_configfile(configfile)
  return if @path

  # basic
  config = YAML.load(File.read(configfile, encoding: "utf-8"))
  @path = Dir.getwd

  # log
  config["log"] ||= {}
  file = config["log"]["filename"] ?
    File.expand_path(Time.now.strftime(config["log"]["filename"]), @path) : STDOUT
  @log = (config["log"]["rotation"].is_a? Integer) ?
    Logger.new(file, config["log"]["rotation"], config["log"]["shift_size"] || 1048576) :
    Logger.new(file, config["log"]["rotation"] || 0)
  if config["log"]["level"]
    @log.level = config["log"]["level"].is_a?(String) ?
      %w[debug info warn error fatal].index(config["log"]["level"].downcase) :
      config["log"]["level"]
  end

  # others
  @lockfile                 = config["lockfile"]
  @maillogs_dir             = File.expand_path(config["maillogs_dir"] || "maillogs", @path)
  @maillogs_period          = config["maillogs_period"] || -1

  @validity_time            = config["validity_time"] || 86400
  @max_check_times          = config["max_check_times"] || 5

  @sleep_time1              = config["sleep_time1"] || 0.1
  @sleep_time2              = config["sleep_time2"] || 1.5
  @permitted_users          = config["permitted_users"]
  @registered_user_only     = !!config["registered_user_only"]
  @use_address_camouflage   = !!config["use_address_camouflage"]
  @enable_html_mail         = !!config["enable_html_mail"]

  @receive_servers          = config["receive_servers"] || []
  @deliver_server           = config["deliver_server"] ||
    { "protocol" => "sendmail", "charset" => "utf-8", "options" => {} }
  
  @register = config["register"] || []
  @register_confirm_subject = config["register_confirm_subject"] || ""
  @register_confirm_body    = config["register_confirm_body"] || ""
  @register_success_subject = config["register_success_subject"] || ""
  @register_success_body    = config["register_success_body"] || ""

  @delete                   = config["delete"] || []
  @delete_confirm_subject   = config["delete_confirm_subject"] || ""
  @delete_confirm_body      = config["delete_confirm_body"] || ""
  @delete_success_subject   = config["delete_success_subject"] || ""
  @delete_success_body      = config["delete_success_body"] || ""

  @forward                  = config["forward"] || []
  @forward_fail_subject     = config["forward_fail_subject"]
  @forward_fail_body        = config["forward_fail_body"]
  @forward_success_subject  = config["forward_success_subject"]
  @forward_success_body     = config["forward_success_body"]
  @reply_to_address         = config["reply_to_address"]

  # database
  if config["database_require"]
    require config["database_require"]
  else
    case config["database"]["adapter"]
    when "mysql2"
      require "mysql2"
    when "postgresql"
      require "pg"
    when "sqlite3"
      require "sqlite3"
    else
      require config["database"]["adapter"]
    end
  end
  ActiveRecord::Base.establish_connection(config["database"])

  # mail server
  @receive_servers = Array(config["receive_server"]) if config["receive_server"]
  deliver_server = @deliver_server
  Mail.defaults do
      delivery_method(deliver_server["protocol"].to_sym,
                      deliver_server["options"].symbolize_keys)
  end
end
lock() { || ... } click to toggle source
# File lib/simple_mailing_list/lock.rb, line 5
def lock()
  if @lockfile
    open(File.expand_path(@lockfile, @path), "w") do |lockfile|
      if lockfile.flock(File::LOCK_EX | File::LOCK_NB)
        @log.debug("Lock successed.")
        yield
        lockfile.flock(File::LOCK_UN)
        @log.debug("Unlocked.")
      else
        @log.debug("Lock failed.")
      end
    end
  else
    yield
  end
end
mail_filename(mail) click to toggle source
# File lib/simple_mailing_list/main.rb, line 407
def mail_filename(mail)
  Time.now.strftime("%Y%m%d-%H%M") +
  "-#{Digest::SHA512.hexdigest(mail.to_s)[0,16]}.eml"
end
move_mail_file(filename, dir) click to toggle source
# File lib/simple_mailing_list/main.rb, line 416
def move_mail_file(filename, dir)
  new_filename = File.join(@maillogs_dir, dir, File.basename(filename))
  File.rename(filename, new_filename)
end
receive_mails() click to toggle source
# File lib/simple_mailing_list/main.rb, line 296
def receive_mails()
  mail_files = []
  lock do
    @receive_servers.each do |server|
      begin
        name = "#{server['options']['user_name']}@#{server['options']['address']}"
        @log.debug "Check mails to #{name}."
        Mail.defaults do
          retriever_method(server["protocol"].to_sym, server["options"].symbolize_keys)
        end
        Mail.all(delete_after_find: true).each do |mail|
          filename = mail_filename(mail)
          File.binwrite(File.join(@maillogs_dir, "temp", filename), mail.raw_source)
          @log.info "I got new mail[#{filename}]."
          mail_files.push(filename)
        end
      rescue => e
        @log.error "Mail Check Error!(#{name})\n  #{error_message(e)}"
      end
    end
  end
  return mail_files
end
register_mail(mail, filename) click to toggle source
# File lib/simple_mailing_list/main.rb, line 50
def register_mail(mail, filename)
  rule = find_rule(@register, mail)
  address = Array(mail.from).first.to_s
  return false if !rule || address.empty?
  regisiter_options = rule["options"] || {}
  @log.info "New register mail from #{address}."
  return true if Confirmation.where(mail_address: address).size >= @max_check_times

  check_code = create_check_code()
  Confirmation.new(
    mail_address: address,
    check_code:   check_code,
    mode:         "register",
    options:      JSON.generate(regisiter_options)
  ).save!
  
  mail = create_mail(
    to:      address,
    subject: @register_confirm_subject,
    body:    @register_confirm_body,
    options: regisiter_options.merge({ "checkcode" => check_code })
  )
  mail.subject += check_code unless mail.subject.index(check_code)
  mail.deliver!
  sleep @sleep_time1
  move_mail_file(filename, "register")
  return true
end
render_text(text, render_options) click to toggle source
# File lib/simple_mailing_list/main.rb, line 412
def render_text(text, render_options)
  render_options.empty? ? text : Liquid::Template.parse(text).render(render_options)
end
unmatched_mail(mail, filename) click to toggle source
# File lib/simple_mailing_list/main.rb, line 276
def unmatched_mail(mail, filename)
  address = Array(mail.from).first.to_s
  @log.warn "Unmatched mail[#{File.basename(filename)}] from #{address}."
  move_mail_file(filename, "unmatched")
  return true
end