module Mournmail

Constants

HAVE_MAIL_GPG
VERSION

Public Class Methods

account_config() click to toggle source
# File lib/mournmail/utils.rb, line 166
def self.account_config
  init_current_account
  @account_config
end
back_to_summary() click to toggle source
# File lib/mournmail/utils.rb, line 139
def self.back_to_summary
  summary_window = Window.list.find { |window|
    window.buffer.name == "*summary*"
  }
  if summary_window
    Window.current = summary_window
  end
end
background(skip_if_busy: false) { || ... } click to toggle source
# File lib/mournmail/utils.rb, line 79
def self.background(skip_if_busy: false)
  @background_thread_mutex.synchronize do
    if background_thread&.alive?
      if skip_if_busy
        return
      else
        raise EditorError, "Another background thread is running"
      end
    end
    self.background_thread = Utils.background {
      begin
        yield
      ensure
        self.background_thread = nil
      end
    }
  end
end
close_groonga_db() click to toggle source
# File lib/mournmail/utils.rb, line 550
def self.close_groonga_db
  if @groonga_db
    @groonga_db.close
  end
end
create_groonga_db(db_path) click to toggle source
# File lib/mournmail/utils.rb, line 519
def self.create_groonga_db(db_path)
  FileUtils.mkdir_p(File.dirname(db_path), mode: 0700)
  db = Groonga::Database.create(path: db_path)

  Groonga::Schema.create_table("Messages", :type => :hash) do |table|
    table.short_text("message_id")
    table.short_text("thread_id")
    table.time("date")
    table.short_text("subject")
    table.short_text("from")
    table.short_text("to")
    table.short_text("cc")
    table.short_text("list_id")
    table.text("body")
  end
  
  Groonga::Schema.create_table("Terms",
                               type: :patricia_trie,
                               normalizer: :NormalizerAuto,
                               default_tokenizer: "TokenBigram") do |table|
    table.index("Messages.subject")
    table.index("Messages.from")
    table.index("Messages.to")
    table.index("Messages.cc")
    table.index("Messages.list_id")
    table.index("Messages.body")
  end

  db
end
current_account() click to toggle source
# File lib/mournmail/utils.rb, line 161
def self.current_account
  init_current_account
  @current_account
end
current_account=(name) click to toggle source
# File lib/mournmail/utils.rb, line 177
def self.current_account=(name)
  unless CONFIG[:mournmail_accounts].key?(name)
    raise ArgumentError, "No such account: #{name}"
  end
  @current_account = name
  @account_config = CONFIG[:mournmail_accounts][name]
end
decode_eword(s) click to toggle source
# File lib/mournmail/utils.rb, line 154
def self.decode_eword(s)
  Mail::Encodings.decode_encode(s, :decode).
    encode(Encoding::UTF_8, replace: "?").gsub(/[\t\n]/, " ")
rescue Encoding::CompatibilityError, Encoding::UndefinedConversionError
  escape_binary(s)
end
define_variable(name, initial_value: nil, attr: nil) click to toggle source
# File lib/mournmail/utils.rb, line 49
def self.define_variable(name, initial_value: nil, attr: nil)
  var_name = "@" + name.to_s
  if !instance_variable_defined?(var_name)
    instance_variable_set(var_name, initial_value)
  end
  case attr
  when :accessor
    singleton_class.send(:attr_accessor, name)
  when :reader
    singleton_class.send(:attr_reader, name)
  when :writer
    singleton_class.send(:attr_writer, name)
  end
end
escape_binary(s) click to toggle source
# File lib/mournmail/utils.rb, line 148
def self.escape_binary(s)
  s.b.gsub(/[\x80-\xff]/n) { |c|
    "<%02X>" % c.ord
  }
end
fetch_summary(mailbox, all: false) click to toggle source
# File lib/mournmail/utils.rb, line 295
def self.fetch_summary(mailbox, all: false)
  if all
    summary = Mournmail::Summary.new(mailbox)
  else
    summary = Mournmail::Summary.load_or_new(mailbox)
  end
  imap_connect do |imap|
    imap.select(mailbox)
    uidvalidity = imap.responses["UIDVALIDITY"].last
    if uidvalidity && summary.uidvalidity &&
        uidvalidity != summary.uidvalidity
      clear = foreground! {
        yes_or_no?("UIDVALIDITY has been changed; Clear cache?")
      }
      if clear
        summary = Mournmail::Summary.new(mailbox)
      end
    end
    summary.uidvalidity = uidvalidity
    uids = imap.uid_search("ALL")
    new_uids = uids - summary.uids
    return summary if new_uids.empty?
    summary.synchronize do
      new_uids.each_slice(1000) do |uid_chunk|
        data = imap.uid_fetch(uid_chunk, ["UID", "ENVELOPE", "FLAGS"])
        data&.each do |i|
          uid = i.attr["UID"]
          next if summary[uid]
          env = i.attr["ENVELOPE"]
          flags = i.attr["FLAGS"]
          item = Mournmail::SummaryItem.new(uid, env.date, env.from,
                                            env.subject, flags)
          summary.add_item(item, env.message_id, env.in_reply_to)
        end
      end
    end
    summary
  end
rescue SocketError, Timeout::Error => e
  foreground do
    message(e.message)
  end
  summary
end
force_utf8(s) click to toggle source
# File lib/mournmail/utils.rb, line 493
def self.force_utf8(s)
  s.dup.force_encoding(Encoding::UTF_8).scrub("?")
end
google_access_token(account = current_account) click to toggle source
# File lib/mournmail/utils.rb, line 228
def self.google_access_token(account = current_account)
  auth_path = File.expand_path("cache/#{account}/google_auth.json",
                               CONFIG[:mournmail_directory])
  FileUtils.mkdir_p(File.dirname(auth_path))
  store = Google::APIClient::FileStore.new(auth_path)
  storage = Google::APIClient::Storage.new(store)
  storage.authorize
  if storage.authorization.nil?
    conf = CONFIG[:mournmail_accounts][account]
    path = File.expand_path(conf[:client_secret_path])
    client_secrets = Google::APIClient::ClientSecrets.load(path)
    auth_client = client_secrets.to_authorization
    auth_client.update!(
      :scope => 'https://mail.google.com/',
      :redirect_uri => 'urn:ietf:wg:oauth:2.0:oob'
    )
    auth_uri = auth_client.authorization_uri.to_s
    auth_client.code = foreground! {
      begin
        Launchy.open(auth_uri)
      rescue Launchy::CommandNotFoundError
        buffer = show_google_auth_uri(auth_uri)
      end
      begin
        Window.echo_area.clear_message
        Window.redisplay
        read_from_minibuffer("Code: ").chomp
      ensure
        if buffer
          kill_buffer(buffer, force: true)
        end
      end
    }
    auth_client.fetch_access_token!
    old_umask = File.umask(077)
    begin
      storage.write_credentials(auth_client)
    ensure
      File.umask(old_umask)
    end
  else
    auth_client = storage.authorization
  end
  auth_client.access_token
end
imap_connect() { |imap| ... } click to toggle source
# File lib/mournmail/utils.rb, line 185
def self.imap_connect
  @imap_mutex.synchronize do
    if keep_alive_thread.nil?
      start_keep_alive_thread
    end
    if @imap.nil? || @imap.disconnected?
      conf = account_config
      auth_type = conf[:imap_options][:auth_type] || "PLAIN"
      password = conf[:imap_options][:password]
      if auth_type == "gmail"
        auth_type = "XOAUTH2"
        password = google_access_token
      end
      Timeout.timeout(CONFIG[:mournmail_imap_connect_timeout]) do
        @imap = Net::IMAP.new(conf[:imap_host],
                              conf[:imap_options])
        @imap.authenticate(auth_type, conf[:imap_options][:user_name],
                           password)
        @mailboxes = @imap.list("", "*").map { |mbox|
          Net::IMAP.decode_utf7(mbox.name)
        }
        if Mournmail.current_mailbox
          @imap.select(Mournmail.current_mailbox)
        end
      end
    end
    yield(@imap)
  end
rescue IOError, Errno::ECONNRESET
  imap_disconnect
  raise
end
imap_disconnect() click to toggle source
# File lib/mournmail/utils.rb, line 218
def self.imap_disconnect
  @imap_mutex.synchronize do
    stop_keep_alive_thread
    if @imap
      @imap.disconnect rescue nil
      @imap = nil
    end
  end
end
index_mail(cache_id, mail) click to toggle source
# File lib/mournmail/utils.rb, line 401
def self.index_mail(cache_id, mail)
  messages_db = Groonga["Messages"]
  unless messages_db.has_key?(cache_id)
    thread_id = find_thread_id(mail, messages_db)
    list_id = (mail["List-Id"] || mail["X-ML-Name"])
    messages_db.add(cache_id,
                    message_id: header_text(mail.message_id),
                    thread_id: header_text(thread_id),
                    date: mail.date&.to_time,
                    subject: header_text(mail.subject),
                    from: header_text(mail["From"]),
                    to: header_text(mail["To"]),
                    cc: header_text(mail["Cc"]),
                    list_id: header_text(list_id),
                    body: body_text(mail))
  end
end
init_current_account() click to toggle source
# File lib/mournmail/utils.rb, line 171
def self.init_current_account
  if @current_account.nil?
    @current_account, @account_config = CONFIG[:mournmail_accounts].first
  end
end
insert_signature() click to toggle source
# File lib/mournmail/utils.rb, line 567
def self.insert_signature
  account = Buffer.current[:mournmail_delivery_account] ||
    Mournmail.current_account
  signature = CONFIG[:mournmail_accounts][account][:signature]
  if signature
    Buffer.current.save_excursion do
      end_of_buffer
      insert("\n")
      insert(signature)
    end
  end
end
mail_cache_path(cache_id) click to toggle source
# File lib/mournmail/utils.rb, line 368
def self.mail_cache_path(cache_id)
  dir = cache_id[0, 2]
  File.expand_path("cache/#{current_account}/mails/#{dir}/#{cache_id}",
                   CONFIG[:mournmail_directory])
end
mailbox_cache_path(mailbox) click to toggle source
# File lib/mournmail/utils.rb, line 363
def self.mailbox_cache_path(mailbox)
  File.expand_path("cache/#{current_account}/mailboxes/#{mailbox}",
                   CONFIG[:mournmail_directory])
end
message_window() click to toggle source
# File lib/mournmail/utils.rb, line 129
def self.message_window
  if Window.list.size == 1
    split_window
    shrink_window(Window.current.lines - 8)
  end
  windows = Window.list
  i = (windows.index(Window.current) + 1) % windows.size
  windows[i]
end
open_groonga_db() click to toggle source
# File lib/mournmail/utils.rb, line 509
def self.open_groonga_db
  db_path = File.expand_path("groonga/#{current_account}/messages.db",
                             CONFIG[:mournmail_directory])
  if File.exist?(db_path)
    @groonga_db = Groonga::Database.open(db_path)
  else
    @groonga_db = create_groonga_db(db_path)
  end
end
parse_mail(s) click to toggle source
# File lib/mournmail/utils.rb, line 556
def self.parse_mail(s)
  Mail.new(s.scrub("??"))
end
read_account_name(prompt, **opts) click to toggle source
# File lib/mournmail/utils.rb, line 560
def self.read_account_name(prompt, **opts)
  f = ->(s) {
    complete_for_minibuffer(s, CONFIG[:mournmail_accounts].keys)
  }
  read_from_minibuffer(prompt, completion_proc: f, **opts)
end
read_mail_cache(cache_id) click to toggle source
# File lib/mournmail/utils.rb, line 374
def self.read_mail_cache(cache_id)
  path = Mournmail.mail_cache_path(cache_id)
  File.read(path)
end
read_mailbox_name(prompt, **opts) click to toggle source
# File lib/mournmail/utils.rb, line 485
def self.read_mailbox_name(prompt, **opts)
  f = ->(s) {
    complete_for_minibuffer(s, @mailboxes)
  }
  mailbox = read_from_minibuffer(prompt, completion_proc: f, **opts)
  Net::IMAP.encode_utf7(mailbox)
end
show_google_auth_uri(auth_uri) click to toggle source
# File lib/mournmail/utils.rb, line 274
  def self.show_google_auth_uri(auth_uri)
    buffer = Buffer.find_or_new("*message*",
                                undo_limit: 0, read_only: true)
    buffer.apply_mode(Mournmail::MessageMode)
    buffer.read_only_edit do
      buffer.clear
      buffer.insert(<<~EOF)
        Open the following URI in your browser and type obtained code:

        #{auth_uri}
      EOF
    end
    window = Mournmail.message_window
    window.buffer = buffer
    buffer
  end
show_summary(summary) click to toggle source
# File lib/mournmail/utils.rb, line 340
def self.show_summary(summary)
  buffer = Buffer.find_or_new("*summary*", undo_limit: 0,
                              read_only: true)
  buffer.apply_mode(Mournmail::SummaryMode)
  buffer.read_only_edit do
    buffer.clear
    buffer.insert(summary.to_s)
  end
  switch_to_buffer(buffer)
  Mournmail.current_mailbox = summary.mailbox
  Mournmail.current_summary = summary
  Mournmail.current_mail = nil
  Mournmail.current_uid = nil
  begin
    buffer.beginning_of_buffer
    buffer.re_search_forward(/^ *\d+ u/)
  rescue SearchError
    buffer.end_of_buffer
    buffer.re_search_backward(/^ *\d+ /, raise_error: false)
  end
  summary_read_command
end
start_keep_alive_thread() click to toggle source
# File lib/mournmail/utils.rb, line 98
def self.start_keep_alive_thread
  @keep_alive_thread_mutex.synchronize do
    if keep_alive_thread
      raise EditorError, "Keep alive thread already running"
    end
    self.keep_alive_thread = Thread.start {
      loop do
        sleep(CONFIG[:mournmail_keep_alive_interval])
        background(skip_if_busy: true) do
          begin
            imap_connect do |imap|
              imap.noop
            end
          rescue => e
            message("Error in IMAP NOOP: #{e.class}: #{e.message}")
          end
        end
      end
    }
  end
end
stop_keep_alive_thread() click to toggle source
# File lib/mournmail/utils.rb, line 120
def self.stop_keep_alive_thread
  @keep_alive_thread_mutex.synchronize do
    if keep_alive_thread
      keep_alive_thread&.kill
      self.keep_alive_thread = nil
    end
  end
end
to_utf8(s, charset) click to toggle source
# File lib/mournmail/utils.rb, line 497
def self.to_utf8(s, charset)
  if /\Autf-8\z/i.match?(charset)
    force_utf8(s)
  else
    begin
      s.encode(Encoding::UTF_8, charset, replace: "?")
    rescue
      force_utf8(NKF.nkf("-w", s))
    end
  end.gsub(/\r\n/, "\n")
end
write_mail_cache(s) click to toggle source
# File lib/mournmail/utils.rb, line 379
def self.write_mail_cache(s)
  header = s.slice(/.*\r\n\r\n/m)
  cache_id = Digest::SHA256.hexdigest(header)
  path = mail_cache_path(cache_id)
  dir = File.dirname(path)
  base = File.basename(path)
  begin
    f = Tempfile.create(["#{base}-", ".tmp"], dir,
                        external_encoding: "ASCII-8BIT", binmode: true)
    begin
      f.write(s)
    ensure
      f.close
    end
  rescue Errno::ENOENT
    FileUtils.mkdir_p(File.dirname(path))
    retry
  end
  File.rename(f.path, path)
  cache_id
end
xoauth2_string(user, access_token) click to toggle source
# File lib/mournmail/utils.rb, line 291
def self.xoauth2_string(user, access_token)
  "user=#{user}\1auth=Bearer #{access_token}\1\1"
end

Private Class Methods

body_text(mail) click to toggle source
# File lib/mournmail/utils.rb, line 450
def body_text(mail)
  if mail.multipart?
    mail.parts.map { |part|
      part_text(part)
    }.join("\n")
  else
    s = mail.body.decoded
    to_utf8(s, mail.charset).gsub(/\r\n/, "\n")
  end
rescue
  ""
end
find_thread_id(mail, messages_db) click to toggle source
# File lib/mournmail/utils.rb, line 422
def find_thread_id(mail, messages_db)
  references = Array(mail.references) | Array(mail.in_reply_to)
  if references.empty?
    mail.message_id
  elsif /\Aredmine\.issue-/.match?(references.first)
    references.first
  else
    parent = messages_db.select { |m|
      references.inject(nil) { |cond, ref|
        if cond.nil?
          m.message_id == ref
        else
          cond | (m.message_id == ref)
        end
      }
    }.first
    if parent
      parent.thread_id
    else
      mail.message_id
    end
  end
end
header_text(s) click to toggle source
# File lib/mournmail/utils.rb, line 446
def header_text(s)
  force_utf8(s.to_s)
end
part_text(part) click to toggle source
# File lib/mournmail/utils.rb, line 463
def part_text(part)
  if part.multipart?
    part.parts.map { |part|
      part_text(part)
    }.join("\n")
  elsif part.main_type == "message" && part.sub_type == "rfc822"
    mail = Mail.new(part.body.raw_source)
    body_text(mail)
  elsif part.attachment?
    force_utf8(part.filename.to_s)
  else
    if part.main_type == "text" && part.sub_type == "plain"
      force_utf8(part.decoded).sub(/(?<!\n)\z/, "\n").gsub(/\r\n/, "\n")
    else
      ""
    end
  end
rescue
  ""
end