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

Class Method 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



102
103
104
105
106
107
108
109
110
111
112
113
114
115
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 102

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)
  # Whether a host was actually CONFIGURED, which is not the same as @host being
  # set: it falls back to "localhost", so it is never nil and cannot answer
  # "can this messenger send?". The capture gate needs that answer, so record it
  # here while the real inputs are still in scope.
  configured_host = host || ENV["TINA4_MAIL_HOST"]
  @smtp_configured = !configured_host.nil? && !configured_host.to_s.empty?
  @mailbox_dir = nil
  @dev_mailbox = 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

#dev_mailboxObject

Send email using Ruby's Net::SMTP Returns { success: true/false, message: "...", id: "..." } The local mailbox, present only once this messenger has captured something (or eagerly, when create_messenger knows it will).



148
149
150
# File 'lib/tina4/messenger.rb', line 148

def dev_mailbox
  @dev_mailbox
end

#encryptionObject (readonly)

Returns the value of attribute encryption.



96
97
98
# File 'lib/tina4/messenger.rb', line 96

def encryption
  @encryption
end

#from_addressObject (readonly)

Returns the value of attribute from_address.



96
97
98
# File 'lib/tina4/messenger.rb', line 96

def from_address
  @from_address
end

#from_nameObject (readonly)

Returns the value of attribute from_name.



96
97
98
# File 'lib/tina4/messenger.rb', line 96

def from_name
  @from_name
end

#hostObject (readonly)

Returns the value of attribute host.



96
97
98
# File 'lib/tina4/messenger.rb', line 96

def host
  @host
end

#imap_encryptionObject (readonly)

Returns the value of attribute imap_encryption.



96
97
98
# File 'lib/tina4/messenger.rb', line 96

def imap_encryption
  @imap_encryption
end

#imap_hostObject (readonly)

Returns the value of attribute imap_host.



96
97
98
# File 'lib/tina4/messenger.rb', line 96

def imap_host
  @imap_host
end

#imap_portObject (readonly)

Returns the value of attribute imap_port.



96
97
98
# File 'lib/tina4/messenger.rb', line 96

def imap_port
  @imap_port
end

#imap_use_tlsObject (readonly)

Returns the value of attribute imap_use_tls.



96
97
98
# File 'lib/tina4/messenger.rb', line 96

def imap_use_tls
  @imap_use_tls
end

#portObject (readonly)

Returns the value of attribute port.



96
97
98
# File 'lib/tina4/messenger.rb', line 96

def port
  @port
end

#use_tlsObject (readonly)

Returns the value of attribute use_tls.



96
97
98
# File 'lib/tina4/messenger.rb', line 96

def use_tls
  @use_tls
end

#usernameObject (readonly)

Returns the value of attribute username.



96
97
98
# File 'lib/tina4/messenger.rb', line 96

def username
  @username
end

Class Method Details

.create_messenger(**options) ⇒ Object

Factory: returns a Messenger configured for the current environment.

Returns ONE concrete type, always. It used to return either a Messenger or a DevMessengerProxy; both happened to expose #send, so Ruby escaped the crash that nodejs#41 describes by luck of naming rather than by design -- but the proxy's #send took no text: keyword, so the documented call raised ArgumentError on a dev messenger. Capture is now a branch inside Messenger#send.

The gate is availability, not verbosity: capture when no SMTP host is configured, send when one is EVEN WITH TINA4_DEBUG ON, and TINA4_MAIL_CAPTURE forces capture.



84
85
86
87
88
89
90
91
92
93
94
# File 'lib/tina4/messenger.rb', line 84

def self.create_messenger(**options)
  mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
  messenger = Messenger.new(**options)
  messenger.instance_variable_set(:@mailbox_dir, mailbox_dir)

  # Attach the mailbox eagerly when this messenger will capture, so callers (and
  # the dev dashboard) can inspect it before the first send.
  messenger.dev_mailbox = DevMailbox.new(mailbox_dir: mailbox_dir) if messenger.should_capture?

  messenger
end

Instance Method Details

#foldersObject

List all IMAP folders.

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



322
323
324
325
326
327
328
329
330
331
332
# File 'lib/tina4/messenger.rb', line 322

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).



233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/tina4/messenger.rb', line 233

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



338
339
340
341
342
343
344
345
# File 'lib/tina4/messenger.rb', line 338

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).



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/tina4/messenger.rb', line 255

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).



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/tina4/messenger.rb', line 296

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



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/tina4/messenger.rb', line 172

def send(to:, subject:, body:, html: false, text: nil, cc: [], bcc: [],
         reply_to: nil, attachments: [], headers: {})
  # Dev capture is a BRANCH here, not a different object handed back by the
  # factory. create_messenger used to return a DevMessengerProxy whose #send had
  # no text: keyword at all, so the documented call raised ArgumentError and the
  # plain-text alternative was silently dropped from the captured message.
  if should_capture?
    return dev_mailbox.capture(
      to: to, subject: subject, body: body, html: html, text: text,
      cc: cc, bcc: bcc, reply_to: reply_to,
      from_address: @from_address, from_name: @from_name,
      attachments: attachments
    )
  end

  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

#should_capture?Boolean

Should send capture locally instead of talking to SMTP?

Availability decides, not verbosity. With no SMTP host configured sending is impossible, so simulate it into a folder rather than failing -- that is what makes a laptop with no mail server usable, and it is the original Tina4 "messages folder" behaviour restored. TINA4_MAIL_CAPTURE forces capture even when a host IS configured.

TINA4_DEBUG deliberately does NOT gate this. Debug must still be able to send: tying capture to it means nobody can test a real send from a dev box. The old gate required debug AND no SMTP host, so a dev box with neither set went straight to localhost:587 and failed.

Returns:

  • (Boolean)


162
163
164
165
166
# File 'lib/tina4/messenger.rb', line 162

def should_capture?
  return true if Tina4::Env.is_truthy(ENV["TINA4_MAIL_CAPTURE"])

  !@smtp_configured
end

#test_connectionObject

Test SMTP connection Returns { success: true/false, message: "..." }



215
216
217
218
219
220
221
222
223
224
# File 'lib/tina4/messenger.rb', line 215

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 }



350
351
352
353
354
355
356
357
# File 'lib/tina4/messenger.rb', line 350

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).



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

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