Class: Tina4::Messenger

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/messenger.rb

Overview

Tina4 Messenger — Email sending (SMTP) and reading (IMAP).

Unified .env-driven configuration with constructor override. Priority: constructor params > .env (TINA4_MAIL_*) > 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!")

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#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, imap_encryption: nil) ⇒ Messenger

Initialize with SMTP config. Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/tina4/messenger.rb', line 80

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, imap_encryption: nil)
  @host         = host         || ENV["TINA4_MAIL_HOST"]     || "localhost"
  @port         = (port        || ENV["TINA4_MAIL_PORT"]     || 587).to_i
  @username     = username     || ENV["TINA4_MAIL_USERNAME"]
  @password     = password     || ENV["TINA4_MAIL_PASSWORD"]

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

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

  # SMTP 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"] || @host
  @imap_port    = (imap_port   || ENV["TINA4_MAIL_IMAP_PORT"] || 993).to_i

  # IMAP encryption: dedicated env var TINA4_MAIL_IMAP_ENCRYPTION (tls/starttls/none).
  # Defaults to "tls" — IMAPS over implicit TLS on port 993 is the safe industry norm.
  env_imap_enc = imap_encryption || ENV["TINA4_MAIL_IMAP_ENCRYPTION"]
  @imap_encryption = (env_imap_enc && !env_imap_enc.to_s.empty?) ? env_imap_enc.to_s.downcase : "tls"
  @imap_use_tls    = %w[tls starttls ssl].include?(@imap_encryption)
end

Instance Attribute Details

#encryptionObject (readonly)

Returns the value of attribute encryption.



74
75
76
# File 'lib/tina4/messenger.rb', line 74

def encryption
  @encryption
end

#from_addressObject (readonly)

Returns the value of attribute from_address.



74
75
76
# File 'lib/tina4/messenger.rb', line 74

def from_address
  @from_address
end

#from_nameObject (readonly)

Returns the value of attribute from_name.



74
75
76
# File 'lib/tina4/messenger.rb', line 74

def from_name
  @from_name
end

#hostObject (readonly)

Returns the value of attribute host.



74
75
76
# File 'lib/tina4/messenger.rb', line 74

def host
  @host
end

#imap_encryptionObject (readonly)

Returns the value of attribute imap_encryption.



74
75
76
# File 'lib/tina4/messenger.rb', line 74

def imap_encryption
  @imap_encryption
end

#imap_hostObject (readonly)

Returns the value of attribute imap_host.



74
75
76
# File 'lib/tina4/messenger.rb', line 74

def imap_host
  @imap_host
end

#imap_portObject (readonly)

Returns the value of attribute imap_port.



74
75
76
# File 'lib/tina4/messenger.rb', line 74

def imap_port
  @imap_port
end

#imap_use_tlsObject (readonly)

Returns the value of attribute imap_use_tls.



74
75
76
# File 'lib/tina4/messenger.rb', line 74

def imap_use_tls
  @imap_use_tls
end

#portObject (readonly)

Returns the value of attribute port.



74
75
76
# File 'lib/tina4/messenger.rb', line 74

def port
  @port
end

#use_tlsObject (readonly)

Returns the value of attribute use_tls.



74
75
76
# File 'lib/tina4/messenger.rb', line 74

def use_tls
  @use_tls
end

#usernameObject (readonly)

Returns the value of attribute username.



74
75
76
# File 'lib/tina4/messenger.rb', line 74

def username
  @username
end

Instance Method Details

#foldersObject

List all IMAP folders.

Raises Tina4::MessengerConnectionError on a connection/protocol failure.



253
254
255
256
257
258
259
260
261
262
263
# File 'lib/tina4/messenger.rb', line 253

def folders
  imap = imap_open("folders")
  begin
    boxes = imap.list("", "*")
    (boxes || []).map(&:name)
  rescue *IMAP_CONNECTION_ERRORS => e
    raise imap_fail("folders", e)
  ensure
    imap_cleanup(imap)
  end
end

#inbox(folder: "INBOX", limit: 20, offset: 0) ⇒ Object

List messages in a folder.

Raises Tina4::MessengerConnectionError on a connection/auth/protocol failure (FAILS LOUD — never returns [] to hide it). A successful fetch from an empty folder returns [] (that is NOT an error).



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/tina4/messenger.rb', line 164

def inbox(folder: "INBOX", limit: 20, offset: 0)
  imap = imap_open("inbox")
  begin
    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) }
  rescue *IMAP_CONNECTION_ERRORS => e
    raise imap_fail("inbox", e)
  ensure
    imap_cleanup(imap)
  end
end

#mark_read(uid, folder: "INBOX") ⇒ Object

Mark a message as read (set Seen flag).

Parameters:

  • uid (String, Integer)

    message UID

  • folder (String) (defaults to: "INBOX")

    IMAP folder name



269
270
271
272
273
274
275
276
# File 'lib/tina4/messenger.rb', line 269

def mark_read(uid, folder: "INBOX")
  imap_connect do |imap|
    imap.select(folder)
    imap.uid_store(uid.to_i, "+FLAGS", [:Seen])
  end
rescue => e
  Tina4::Log.error("IMAP mark_read failed: #{e.message}")
end

#read(uid, folder: "INBOX", mark_read: true) ⇒ Object

Read a single message by UID.

Raises Tina4::MessengerConnectionError on a connection/protocol failure. A successful fetch for a non-existent UID returns nil (that is NOT an error).



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/tina4/messenger.rb', line 186

def read(uid, folder: "INBOX", mark_read: true)
  imap = imap_open("read")
  begin
    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)
  rescue *IMAP_CONNECTION_ERRORS => e
    raise imap_fail("read", e)
  ensure
    imap_cleanup(imap)
  end
end

#search(folder: "INBOX", subject: nil, sender: nil, since: nil, before: nil, unseen_only: false, limit: 20) ⇒ Object

Search messages with filters.

Raises Tina4::MessengerConnectionError on a connection/protocol failure. A successful search with no matches returns [] (NOT an error).



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/tina4/messenger.rb', line 227

def search(folder: "INBOX", subject: nil, sender: nil, since: nil,
           before: nil, unseen_only: false, limit: 20)
  imap = imap_open("search")
  begin
    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) }
  rescue *IMAP_CONNECTION_ERRORS => e
    raise imap_fail("search", e)
  ensure
    imap_cleanup(imap)
  end
end

#send(to:, subject:, body:, html: false, text: nil, cc: [], bcc: [], reply_to: nil, attachments: [], headers: {}) ⇒ Object

Send email using Ruby’s Net::SMTP Returns { success: true/false, message: “…”, id: “…” }



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/tina4/messenger.rb', line 116

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_connectionObject

Test SMTP connection Returns { success: true/false, message: “…” }



146
147
148
149
150
151
152
153
154
155
# File 'lib/tina4/messenger.rb', line 146

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

#test_imap_connectionHash

Test IMAP connectivity without reading messages.

Returns:

  • (Hash)

    { success: Boolean, message: String }



281
282
283
284
285
286
287
288
# File 'lib/tina4/messenger.rb', line 281

def test_imap_connection
  imap_connect do |_imap|
    # Connection succeeded
  end
  { success: true, message: "Connected to #{@imap_host}:#{@imap_port}" }
rescue => e
  { success: false, message: "IMAP connection failed: #{e.message}" }
end

#unread(folder: "INBOX") ⇒ Object

Count unread messages.

Raises Tina4::MessengerConnectionError on a connection/protocol failure. A successful query with no unseen messages returns 0 (NOT an error).



210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/tina4/messenger.rb', line 210

def unread(folder: "INBOX")
  imap = imap_open("unread")
  begin
    imap.select(folder)
    uids = imap.uid_search(["UNSEEN"])
    uids.length
  rescue *IMAP_CONNECTION_ERRORS => e
    raise imap_fail("unread", e)
  ensure
    imap_cleanup(imap)
  end
end