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, imap_username: nil, imap_password: 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
143
144
145
146
147
148
149
150
151
# 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,
               imap_username: nil, imap_password: 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)

  # IMAP credentials, independent of SMTP. Dedicated
  # TINA4_MAIL_IMAP_USERNAME/_PASSWORD, falling back to the SMTP
  # TINA4_MAIL_USERNAME/_PASSWORD. Ruby authenticated IMAP to the SMTP
  # account, so an app whose mailbox for READING differs from its SMTP relay
  # account read the wrong mailbox. Explicit constructor args win (ADR-0041).
  @imap_username = imap_username || ENV["TINA4_MAIL_IMAP_USERNAME"] || @username
  @imap_password = imap_password || ENV["TINA4_MAIL_IMAP_PASSWORD"] || @password
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).



157
158
159
# File 'lib/tina4/messenger.rb', line 157

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_passwordObject (readonly)

Returns the value of attribute imap_password.



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

def imap_password
  @imap_password
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

#imap_usernameObject (readonly)

Returns the value of attribute imap_username.



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

def imap_username
  @imap_username
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

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

Delete a message: flag it Deleted and expunge. Destructive, so it FAILS LOUD — a connection/protocol failure raises MessengerConnectionError rather than silently reporting success (parity with the Python master, which also raises). Returns true when the expunge completes.

Parameters:

  • uid (String, Integer)

    message UID

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

    IMAP folder name



450
451
452
453
454
455
456
457
458
459
460
461
462
# File 'lib/tina4/messenger.rb', line 450

def delete(uid, folder: "INBOX")
  imap = imap_open("delete")
  begin
    imap.select(folder)
    imap.uid_store(uid, "+FLAGS", [:Deleted])
    imap.expunge
    true
  rescue *IMAP_CONNECTION_ERRORS => e
    raise imap_fail("delete", e)
  ensure
    imap_cleanup(imap)
  end
end

#foldersObject

List all IMAP folders.

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



403
404
405
406
407
408
409
410
411
412
413
# File 'lib/tina4/messenger.rb', line 403

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, **opts) ⇒ Object

List messages in a folder.

Callable POSITIONALLY — inbox("INBOX", 10, 0) — or by keyword — inbox(folder: "INBOX", limit: 10). Node moved to folder-first in 3.13.95 to satisfy a contract Ruby could not satisfy at all; this closes that loop.

Each item is EXACTLY subject, from:String, to:String, date:ISO-8601, snippet, seen:Boolean — the settled cross-framework shape.

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



283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/tina4/messenger.rb', line 283

def inbox(folder = "INBOX", limit = 20, offset = 0, **opts)
  folder = opts[:folder] if opts.key?(:folder)
  limit  = opts[:limit]  if opts.key?(:limit)
  offset = opts[:offset] if opts.key?(:offset)

  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?

    # BODY.PEEK[] fetches the full message WITHOUT setting \Seen — listing an
    # inbox must never mark messages read — so parse_envelope can build a
    # decoded, transfer-decoded, tag-stripped snippet. tina4: heavier than a
    # header-only fetch for large mailboxes; fetch BODYSTRUCTURE + the text
    # part only if this ever gets hot.
    envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "BODY.PEEK[]"])
    # uid_fetch returns rows in SERVER (ascending) order however the uids
    # were asked for, so the newest-first page above was silently re-sorted
    # and inbox(limit: 1) handed back the OLDEST message. Selection was
    # right; presentation was not. Restore the requested order.
    by_uid = (envelopes || []).each_with_object({}) { |m, h| h[m.attr["UID"]] = m }
    page.filter_map { |uid| by_uid[uid] }.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



419
420
421
422
423
424
425
426
# File 'lib/tina4/messenger.rb', line 419

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

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

Mark a message as unread (clear Seen flag). Mirror of mark_read; same result-style error handling (logs, returns nil) — the two are a matched pair of idempotent flag toggles.

Parameters:

  • uid (String, Integer)

    message UID

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

    IMAP folder name



434
435
436
437
438
439
440
441
# File 'lib/tina4/messenger.rb', line 434

def mark_unread(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_unread failed: #{e.message}")
end

#read(uid, folder = "INBOX", mark_read = true, **opts) ⇒ Object

Read a single message by UID.

Callable POSITIONALLY — read(uid, "INBOX") — or by keyword — read(uid, folder: "INBOX"). Returns EXACTLY these 10 keys: subject, from:String, to:String, cc:String, date:ISO-8601, body_text, body_html, attachments, headers. Message-ID lives in headers, never a top-level key. Each attachments item is content_type, size, content where content is the RAW DECODED BYTES of the part (issue #69).

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



326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'lib/tina4/messenger.rb', line 326

def read(uid, folder = "INBOX", mark_read = true, **opts)
  folder    = opts[:folder]    if opts.key?(:folder)
  mark_read = opts[:mark_read] if opts.key?(:mark_read)

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



370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
# File 'lib/tina4/messenger.rb', line 370

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?

    # BODY.PEEK[] (no \Seen) so search results carry the same decoded snippet
    # as inbox(); search shares parse_envelope and therefore the item shape.
    envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "BODY.PEEK[]"])
    # uid_fetch returns rows in SERVER (ascending) order however the uids
    # were asked for, so the newest-first page above was silently re-sorted
    # and inbox(limit: 1) handed back the OLDEST message. Selection was
    # right; presentation was not. Restore the requested order.
    by_uid = (envelopes || []).each_with_object({}) { |m, h| h[m.attr["UID"]] = m }
    page.filter_map { |uid| by_uid[uid] }.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



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
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/tina4/messenger.rb', line 181

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

  # TINA4_MAIL_REDIRECT_TO (MAIL-DEC-01): the REAL-SEND path only --
  # capture already returned above, so this never touches the capture
  # branch. When the list is non-empty, replace every recipient with the
  # redirect list (so ONLY the dev list receives the mail, never the real
  # recipients) and preserve the originals in a header. Subject/body/
  # attachments are untouched; #send's return shape is unchanged. Read
  # fresh per call, matching should_capture?'s own env-read style.
  redirect_to = parse_mail_redirect_list(ENV["TINA4_MAIL_REDIRECT_TO"])
  unless redirect_to.empty?
    original_to = (normalize_recipients(to) + normalize_recipients(cc) + normalize_recipients(bcc)).join(", ")
    to = redirect_to
    cc = []
    bcc = []
    headers = (headers || {}).merge("X-Tina4-Original-To" => original_to)
  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

#send_template(to:, subject:, template:, data: {}, **kwargs) ⇒ Object

Send an email whose HTML body is rendered from a Frond template string. Parity with the Python master's send_template. Extra keyword args (cc, bcc, attachments, reply_to, headers, text) pass straight through to #send. If Frond is unusable, the raw template string is sent as-is (a graceful fallback, mirroring Python's ImportError branch).

mail.send_template(to: "u@x.com", subject: "Hi {{ name }}",
                 template: "<h1>Hi {{ name }}</h1>", data: { "name" => "Al" })


246
247
248
249
250
251
252
253
254
# File 'lib/tina4/messenger.rb', line 246

def send_template(to:, subject:, template:, data: {}, **kwargs)
  body = begin
    Tina4::Frond.new.render_string(template, data || {})
  rescue StandardError => e
    Tina4::Log.error("Messenger send_template render failed: #{e.message}")
    template
  end
  send(to: to, subject: subject, body: body, html: true, **kwargs)
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)


171
172
173
174
175
# File 'lib/tina4/messenger.rb', line 171

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: "..." }



258
259
260
261
262
263
264
265
266
267
# File 'lib/tina4/messenger.rb', line 258

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 }



467
468
469
470
471
472
473
474
# File 'lib/tina4/messenger.rb', line 467

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



353
354
355
356
357
358
359
360
361
362
363
364
# File 'lib/tina4/messenger.rb', line 353

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