lib/tina4/messenger.rb



# frozen_string_literal: true

begin
  require "net/smtp"
rescue LoadError
  # net-smtp gem needed on Ruby >= 4.0: gem install net-smtp
end
begin
  require "net/imap"
rescue LoadError
  # net-imap gem needed on Ruby >= 4.0: gem install net-imap
end
require "base64"
require "securerandom"
require "time"

module Tina4
  # Tina4 Messenger — Email sending (SMTP) and reading (IMAP).
  #
  # Unified .env-driven configuration with constructor override.
  # Priority: constructor params > .env (TINA4_MAIL_* with SMTP_* fallback) > sensible defaults
  #
  #   # .env
  #   TINA4_MAIL_HOST=smtp.gmail.com
  #   TINA4_MAIL_PORT=587
  #   TINA4_MAIL_USERNAME=user@gmail.com
  #   TINA4_MAIL_PASSWORD=app-password
  #   TINA4_MAIL_FROM=noreply@myapp.com
  #   TINA4_MAIL_ENCRYPTION=tls
  #   TINA4_MAIL_IMAP_HOST=imap.gmail.com
  #   TINA4_MAIL_IMAP_PORT=993
  #
  #   mail = Messenger.new                                        # reads from .env
  #   mail = Messenger.new(host: "smtp.office365.com", port: 587) # override
  #   mail.send(to: "user@test.com", subject: "Welcome", body: "<h1>Hello!</h1>", html: true, text: "Hello!")
  #
  class Messenger
    attr_reader :host, :port, :username, :from_address, :from_name,
                :imap_host, :imap_port, :use_tls, :encryption

    # Initialize with SMTP config.
    # Priority: constructor params > ENV (TINA4_MAIL_* with SMTP_* fallback) > sensible defaults
    def initialize(host: nil, port: nil, username: nil, password: nil,
                   from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
                   imap_host: nil, imap_port: nil)
      @host         = host         || ENV["TINA4_MAIL_HOST"]     || ENV["SMTP_HOST"]     || "localhost"
      @port         = (port        || ENV["TINA4_MAIL_PORT"]     || ENV["SMTP_PORT"]     || 587).to_i
      @username     = username     || ENV["TINA4_MAIL_USERNAME"] || ENV["SMTP_USERNAME"]
      @password     = password     || ENV["TINA4_MAIL_PASSWORD"] || ENV["SMTP_PASSWORD"]

      resolved_from = from_address || ENV["TINA4_MAIL_FROM"]     || ENV["SMTP_FROM"]
      @from_address = resolved_from || @username || "noreply@localhost"

      @from_name    = from_name    || ENV["TINA4_MAIL_FROM_NAME"] || ENV["SMTP_FROM_NAME"] || ""

      # Encryption: constructor > .env > backward-compat use_tls > default "tls"
      env_encryption = encryption  || ENV["TINA4_MAIL_ENCRYPTION"]
      if env_encryption
        @encryption = env_encryption.downcase
      elsif !use_tls.nil?
        @encryption = use_tls ? "tls" : "none"
      else
        @encryption = "tls"
      end
      @use_tls = %w[tls starttls].include?(@encryption)

      @imap_host    = imap_host    || ENV["TINA4_MAIL_IMAP_HOST"] || ENV["IMAP_HOST"] || @host
      @imap_port    = (imap_port   || ENV["TINA4_MAIL_IMAP_PORT"] || ENV["IMAP_PORT"] || 993).to_i
    end

    # Send email using Ruby's Net::SMTP
    # Returns { success: true/false, message: "...", id: "..." }
    def send(to:, subject:, body:, html: false, text: nil, cc: [], bcc: [],
             reply_to: nil, attachments: [], headers: {})
      message_id = "<#{SecureRandom.uuid}@#{@host}>"
      raw = build_message(
        to: to, subject: subject, body: body, html: html, text: text,
        cc: cc, bcc: bcc, reply_to: reply_to,
        attachments: attachments, headers: headers,
        message_id: message_id
      )

      all_recipients = normalize_recipients(to) +
                       normalize_recipients(cc) +
                       normalize_recipients(bcc)

      smtp = Net::SMTP.new(@host, @port)
      smtp.enable_starttls if @use_tls

      smtp.start(@host, @username, @password, auth_method) do |conn|
        conn.send_message(raw, @from_address, all_recipients)
      end

      Tina4::Log.info("Email sent to #{Array(to).join(', ')}: #{subject}")
      { success: true, message: "Email sent successfully", id: message_id }
    rescue => e
      Tina4::Log.error("Email send failed: #{e.message}")
      { success: false, message: e.message, id: nil }
    end

    # Test SMTP connection
    # Returns { success: true/false, message: "..." }
    def test_connection
      smtp = Net::SMTP.new(@host, @port)
      smtp.enable_starttls if @use_tls
      smtp.start(@host, @username, @password, auth_method) do |_conn|
        # connection succeeded
      end
      { success: true, message: "SMTP connection successful" }
    rescue => e
      { success: false, message: e.message }
    end

    # ── IMAP operations ──────────────────────────────────────────────────

    # List messages in a folder
    def inbox(folder: "INBOX", limit: 20, offset: 0)
      imap_connect do |imap|
        imap.select(folder)
        uids = imap.uid_search(["ALL"])
        uids = uids.reverse # newest first
        page = uids[offset, limit] || []
        return [] if page.empty?

        envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
        (envelopes || []).map { |msg| parse_envelope(msg) }
      end
    rescue => e
      Tina4::Log.error("IMAP inbox failed: #{e.message}")
      []
    end

    # Read a single message by UID
    def read(uid, folder: "INBOX", mark_read: true)
      imap_connect do |imap|
        imap.select(folder)
        data = imap.uid_fetch(uid, ["ENVELOPE", "FLAGS", "BODY[]", "RFC822.SIZE"])
        return nil if data.nil? || data.empty?

        if mark_read
          imap.uid_store(uid, "+FLAGS", [:Seen])
        end

        msg = data.first
        parse_full_message(msg)
      end
    rescue => e
      Tina4::Log.error("IMAP read failed: #{e.message}")
      nil
    end

    # Count unread messages
    def unread(folder: "INBOX")
      imap_connect do |imap|
        imap.select(folder)
        uids = imap.uid_search(["UNSEEN"])
        uids.length
      end
    rescue => e
      Tina4::Log.error("IMAP unread count failed: #{e.message}")
      0
    end

    # Search messages with filters
    def search(folder: "INBOX", subject: nil, sender: nil, since: nil,
               before: nil, unseen_only: false, limit: 20)
      imap_connect do |imap|
        imap.select(folder)
        criteria = build_search_criteria(
          subject: subject, sender: sender, since: since,
          before: before, unseen_only: unseen_only
        )
        uids = imap.uid_search(criteria)
        uids = uids.reverse
        page = uids[0, limit] || []
        return [] if page.empty?

        envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
        (envelopes || []).map { |msg| parse_envelope(msg) }
      end
    rescue => e
      Tina4::Log.error("IMAP search failed: #{e.message}")
      []
    end

    # List all IMAP folders
    def folders
      imap_connect do |imap|
        boxes = imap.list("", "*")
        (boxes || []).map(&:name)
      end
    rescue => e
      Tina4::Log.error("IMAP folders failed: #{e.message}")
      []
    end

    private

    # ── SMTP helpers ─────────────────────────────────────────────────────

    def auth_method
      return :plain if @username && @password

      nil
    end

    def normalize_recipients(value)
      case value
      when nil then []
      when String then [value]
      when Array then value.flatten.compact
      else [value.to_s]
      end
    end

    def format_address(address, name = nil)
      if name && !name.empty?
        "#{name} <#{address}>"
      else
        address
      end
    end

    def build_message(to:, subject:, body:, html:, text: nil, cc:, bcc:, reply_to:,
                      attachments:, headers:, message_id:)
      boundary = "----=_Tina4_#{SecureRandom.hex(16)}"
      alt_boundary = "----=_Tina4Alt_#{SecureRandom.hex(16)}"
      date = Time.now.strftime("%a, %d %b %Y %H:%M:%S %z")
      has_text_alt = !text.nil? && html

      parts = []
      parts << "From: #{format_address(@from_address, @from_name)}"
      parts << "To: #{Array(to).join(', ')}"
      parts << "Cc: #{Array(cc).join(', ')}" unless Array(cc).empty?
      parts << "Subject: #{encode_header(subject)}"
      parts << "Date: #{date}"
      parts << "Message-ID: #{message_id}"
      parts << "MIME-Version: 1.0"
      parts << "Reply-To: #{reply_to}" if reply_to

      headers.each { |k, v| parts << "#{k}: #{v}" }

      if !attachments.empty?
        parts << "Content-Type: multipart/mixed; boundary=\"#{boundary}\""
        parts << ""
        parts << "--#{boundary}"

        # Body part (with optional text alternative)
        if has_text_alt
          parts << "Content-Type: multipart/alternative; boundary=\"#{alt_boundary}\""
          parts << ""
          parts << "--#{alt_boundary}"
          parts << "Content-Type: text/plain; charset=UTF-8"
          parts << "Content-Transfer-Encoding: base64"
          parts << ""
          parts << Base64.encode64(text)
          parts << "--#{alt_boundary}"
          parts << "Content-Type: text/html; charset=UTF-8"
          parts << "Content-Transfer-Encoding: base64"
          parts << ""
          parts << Base64.encode64(body)
          parts << "--#{alt_boundary}--"
        else
          content_type = html ? "text/html" : "text/plain"
          parts << "Content-Type: #{content_type}; charset=UTF-8"
          parts << "Content-Transfer-Encoding: base64"
          parts << ""
          parts << Base64.encode64(body)
        end

        # Attachment parts
        attachments.each do |attachment|
          parts << "--#{boundary}"
          parts.concat(build_attachment_part(attachment))
        end
        parts << "--#{boundary}--"
      elsif has_text_alt
        # Text alternative without attachments
        parts << "Content-Type: multipart/alternative; boundary=\"#{alt_boundary}\""
        parts << ""
        parts << "--#{alt_boundary}"
        parts << "Content-Type: text/plain; charset=UTF-8"
        parts << "Content-Transfer-Encoding: base64"
        parts << ""
        parts << Base64.encode64(text)
        parts << "--#{alt_boundary}"
        parts << "Content-Type: text/html; charset=UTF-8"
        parts << "Content-Transfer-Encoding: base64"
        parts << ""
        parts << Base64.encode64(body)
        parts << "--#{alt_boundary}--"
      else
        content_type = html ? "text/html" : "text/plain"
        parts << "Content-Type: #{content_type}; charset=UTF-8"
        parts << "Content-Transfer-Encoding: base64"
        parts << ""
        parts << Base64.encode64(body)
      end

      parts.join("\r\n")
    end

    def build_attachment_part(attachment)
      lines = []
      if attachment.is_a?(Hash)
        filename = attachment[:filename] || attachment[:name] || "attachment"
        content = attachment[:content] || ""
        mime = attachment[:mime_type] || attachment[:content_type] || "application/octet-stream"
      elsif attachment.is_a?(String) && File.exist?(attachment)
        filename = File.basename(attachment)
        content = File.binread(attachment)
        mime = guess_mime_type(filename)
      else
        return []
      end

      encoded = content.is_a?(String) && !content.ascii_only? ? Base64.encode64(content) : Base64.encode64(content.to_s)

      lines << "Content-Type: #{mime}; name=\"#{filename}\""
      lines << "Content-Disposition: attachment; filename=\"#{filename}\""
      lines << "Content-Transfer-Encoding: base64"
      lines << ""
      lines << encoded
      lines
    end

    def encode_header(value)
      if value.ascii_only?
        value
      else
        "=?UTF-8?B?#{Base64.strict_encode64(value)}?="
      end
    end

    def guess_mime_type(filename)
      ext = File.extname(filename).downcase
      {
        ".txt"  => "text/plain",
        ".html" => "text/html",
        ".htm"  => "text/html",
        ".css"  => "text/css",
        ".js"   => "application/javascript",
        ".json" => "application/json",
        ".xml"  => "application/xml",
        ".pdf"  => "application/pdf",
        ".zip"  => "application/zip",
        ".gz"   => "application/gzip",
        ".tar"  => "application/x-tar",
        ".png"  => "image/png",
        ".jpg"  => "image/jpeg",
        ".jpeg" => "image/jpeg",
        ".gif"  => "image/gif",
        ".svg"  => "image/svg+xml",
        ".csv"  => "text/csv",
        ".doc"  => "application/msword",
        ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        ".xls"  => "application/vnd.ms-excel",
        ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
      }.fetch(ext, "application/octet-stream")
    end

    # ── IMAP helpers ─────────────────────────────────────────────────────

    def imap_connect(&block)
      imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @use_tls)
      imap.login(@username, @password)
      result = block.call(imap)
      imap.logout
      imap.disconnect
      result
    end

    def parse_envelope(fetch_data)
      env = fetch_data.attr["ENVELOPE"]
      flags = fetch_data.attr["FLAGS"] || []
      size = fetch_data.attr["RFC822.SIZE"] || 0

      {
        uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"] : nil,
        subject: env.subject ? decode_mime_header(env.subject) : "",
        from: format_imap_address(env.from),
        to: format_imap_address(env.to),
        date: env.date,
        flags: flags.map(&:to_s),
        read: flags.include?(:Seen),
        size: size
      }
    end

    def parse_full_message(fetch_data)
      env = fetch_data.attr["ENVELOPE"]
      flags = fetch_data.attr["FLAGS"] || []
      raw_body = fetch_data.attr["BODY[]"] || ""

      body_text, body_html = extract_body_parts(raw_body)

      {
        uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"] : nil,
        subject: env.subject ? decode_mime_header(env.subject) : "",
        from: format_imap_address(env.from),
        to: format_imap_address(env.to),
        cc: format_imap_address(env.cc),
        date: env.date,
        message_id: env.message_id,
        flags: flags.map(&:to_s),
        read: flags.include?(:Seen),
        body: body_text,
        html: body_html,
        raw: raw_body
      }
    end

    def format_imap_address(addresses)
      return [] if addresses.nil?

      addresses.map do |addr|
        email = "#{addr.mailbox}@#{addr.host}"
        if addr.name && !addr.name.empty?
          { name: decode_mime_header(addr.name), email: email }
        else
          { name: nil, email: email }
        end
      end
    end

    def decode_mime_header(value)
      return "" if value.nil?

      value.gsub(/=\?([^?]+)\?([BbQq])\?([^?]+)\?=/) do
        charset = Regexp.last_match(1)
        encoding = Regexp.last_match(2).upcase
        encoded = Regexp.last_match(3)

        decoded = case encoding
                  when "B"
                    Base64.decode64(encoded)
                  when "Q"
                    encoded.gsub("_", " ").gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
                  else
                    encoded
                  end

        decoded.force_encoding(charset).encode("UTF-8", invalid: :replace, undef: :replace)
      end
    end

    def extract_body_parts(raw)
      text_body = nil
      html_body = nil

      # Check for multipart
      if raw =~ /Content-Type:\s*multipart\/\w+;\s*boundary="?([^"\s;]+)"?/i
        boundary = Regexp.last_match(1)
        parts = raw.split("--#{boundary}")
        parts.each do |part|
          next if part.strip == "" || part.strip == "--"

          if part =~ /Content-Type:\s*text\/plain/i
            text_body = extract_part_body(part)
          elsif part =~ /Content-Type:\s*text\/html/i
            html_body = extract_part_body(part)
          end
        end
      elsif raw =~ /Content-Type:\s*text\/html/i
        html_body = extract_part_body(raw)
      else
        text_body = extract_part_body(raw)
      end

      [text_body || "", html_body || ""]
    end

    def extract_part_body(part)
      # Split headers from body at double CRLF or double LF
      header_body = part.split(/\r?\n\r?\n/, 2)
      return "" unless header_body.length > 1

      body = header_body[1].strip
      headers = header_body[0]

      if headers =~ /Content-Transfer-Encoding:\s*base64/i
        Base64.decode64(body).force_encoding("UTF-8")
      elsif headers =~ /Content-Transfer-Encoding:\s*quoted-printable/i
        body.gsub(/=\r?\n/, "").gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
      else
        body
      end
    end

    def build_search_criteria(subject:, sender:, since:, before:, unseen_only:)
      criteria = []
      criteria.push("SUBJECT", subject) if subject
      criteria.push("FROM", sender) if sender
      criteria.push("SINCE", format_imap_date(since)) if since
      criteria.push("BEFORE", format_imap_date(before)) if before
      criteria << "UNSEEN" if unseen_only
      criteria << "ALL" if criteria.empty?
      criteria
    end

    def format_imap_date(date)
      case date
      when Time, DateTime
        date.strftime("%d-%b-%Y")
      when Date
        date.strftime("%d-%b-%Y")
      when String
        date
      else
        date.to_s
      end
    end
  end

  # Factory: returns a DevMailbox-intercepting messenger in dev mode,
  # or a real Messenger in production.
  def self.create_messenger(**options)
    dev_mode = Tina4::Env.truthy?(ENV["TINA4_DEBUG"])

    smtp_configured = (ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?) ||
                      (ENV["SMTP_HOST"] && !ENV["SMTP_HOST"].empty?)

    if dev_mode && !smtp_configured
      mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
      mailbox = DevMailbox.new(mailbox_dir: mailbox_dir)
      DevMessengerProxy.new(mailbox, **options)
    else
      Messenger.new(**options)
    end
  end

  # Proxy that wraps DevMailbox with the same interface as Messenger#send
  class DevMessengerProxy
    attr_reader :mailbox

    def initialize(mailbox, **options)
      @mailbox = mailbox
      @from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] || ENV["SMTP_FROM"] || "dev@localhost"
      @from_name    = options[:from_name]    || ENV["TINA4_MAIL_FROM_NAME"] || ENV["SMTP_FROM_NAME"] || "Dev Mailer"
    end

    def send(to:, subject:, body:, html: false, cc: [], bcc: [],
             reply_to: nil, attachments: [], headers: {})
      @mailbox.capture(
        to: to, subject: subject, body: body, html: html,
        cc: cc, bcc: bcc, reply_to: reply_to,
        from_address: @from_address, from_name: @from_name,
        attachments: attachments
      )
    end

    def test_connection
      { success: true, message: "DevMailbox mode — no SMTP connection needed" }
    end

    def inbox(**args)  = @mailbox.inbox(**args)
    def read(...)      = @mailbox.read(...)
    def unread(...)    = @mailbox.unread_count
    def search(**args) = @mailbox.inbox(**args)
    def folders        = ["inbox", "outbox"]
  end
end