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



434
435
436
437
438
439
440
441
442
443
444
445
446
# File 'lib/tina4/messenger.rb', line 434

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.



387
388
389
390
391
392
393
394
395
396
397
# File 'lib/tina4/messenger.rb', line 387

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



267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/tina4/messenger.rb', line 267

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



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

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



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

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



310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/tina4/messenger.rb', line 310

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



354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
# File 'lib/tina4/messenger.rb', line 354

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
# 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

  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" })


230
231
232
233
234
235
236
237
238
# File 'lib/tina4/messenger.rb', line 230

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



242
243
244
245
246
247
248
249
250
251
# File 'lib/tina4/messenger.rb', line 242

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 }



451
452
453
454
455
456
457
458
# File 'lib/tina4/messenger.rb', line 451

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



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

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