pikuri-thunderbird 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Thunderbird
5
+ # The +thunderbird_calendar_search+ tool — search local + cached
6
+ # Thunderbird calendars by title/description/location. Inbound-only; its
7
+ # read half is +thunderbird_calendar_read+.
8
+ #
9
+ # Recall-first (words OR-matched, ranked by matched-word count) on purpose,
10
+ # mirroring {MailSearch}: tighten it to AND and the model — primed by the
11
+ # forgiving mail search — reads the sparser hits as "no such event".
12
+ #
13
+ # == Empty-corpus guidance (Q6)
14
+ #
15
+ # Thunderbird keeps a CalDAV calendar's events *in memory only* unless
16
+ # "Offline Support" is enabled, so a calendar can look full in the UI yet
17
+ # have nothing on disk. When no events are cached, this tool returns a
18
+ # self-fixing observation telling the user the one-click fix rather than a
19
+ # bare "no results" — and the same fact rides the description, so the LLM
20
+ # can advise proactively before any empty search.
21
+ #
22
+ # Sharing: +P_shared_locked+ — no state of its own, and the {Calendar}
23
+ # backend it queries locks; see that class's +== Sharing+.
24
+ class CalendarSearch < Pikuri::Tool
25
+ DEFAULT_LIMIT = 15
26
+ MAX_LIMIT = 50
27
+
28
+ # @return [String] the enable-Offline-Support guidance, reused by the
29
+ # description and the empty-corpus observation.
30
+ OFFLINE_HINT =
31
+ 'No calendar events are cached to disk. If Thunderbird shows events but ' \
32
+ 'search finds none, enable Offline Support on each calendar ' \
33
+ '(right-click the calendar → Properties → Offline Support) so its events ' \
34
+ 'are stored locally and become searchable.'
35
+
36
+ # @return [String] opencode-shape description.
37
+ DESCRIPTION = <<~DESC
38
+ Search the user's local and cached Thunderbird calendars by title, description or location.
39
+
40
+ Usage:
41
+ - Free-text query and/or a date range — both optional; supply at least one.
42
+ - To list everything on a day or in a span (e.g. "tomorrow's meetings"), pass a broad or empty query with after/before; a single day is after=before=that date.
43
+ - Each query word is matched independently (across title, description, and location) and events matching more of the words rank first — so extra words broaden the search and surface partial matches, they don't require every word. Matching ignores accents and case. To narrow, add a date range rather than more words.
44
+ - Optionally restrict to recurring events (a repeating series) or single one-off events with the recurrence filter; omit it to get both.
45
+ - Reads Thunderbird's on-disk calendar store — it never connects to any calendar server.
46
+ - Only events cached to disk are visible. A CalDAV calendar keeps events in memory unless its "Offline Support" is enabled (right-click the calendar → Properties → Offline Support); if a search comes back empty but the calendar looks full, that setting is why.
47
+ - Read an event in full with thunderbird_calendar_read.
48
+ DESC
49
+
50
+ # @param backend [Calendar] the calendar backend.
51
+ # @return [CalendarSearch]
52
+ def initialize(backend:)
53
+ @backend = backend
54
+ super(
55
+ name: 'thunderbird_calendar_search',
56
+ description: DESCRIPTION,
57
+ parameters: Parameters.build { |p|
58
+ p.optional_string :query, 'Words to find in event title/description/location, e.g. "standup". Omit to list everything in the date range.'
59
+ p.optional_string :after, 'Only events on/after this date, e.g. "2026-01-01".'
60
+ p.optional_string :before, 'Only events on/before this date, e.g. "2026-12-31".'
61
+ p.optional_enum :recurrence, 'Restrict by repetition: "recurring" for repeating series only, "single" for one-off events only. Omit for both.', values: %w[recurring single]
62
+ p.optional_integer :limit, "Max events (default #{DEFAULT_LIMIT}, max #{MAX_LIMIT}), e.g. 15."
63
+ },
64
+ execute: lambda { |query: nil, after: nil, before: nil, recurrence: nil, limit: DEFAULT_LIMIT|
65
+ CalendarSearch.run_search(backend: @backend, query:, after:, before:, recurrence:, limit:)
66
+ },
67
+ trifecta_legs: Pikuri::Thunderbird::INBOUND_LEGS
68
+ )
69
+ end
70
+
71
+ # @param recurrence [String, nil] +"recurring"+, +"single"+, or +nil+ for
72
+ # both (mapped to the backend's tri-state +recurring:+ flag).
73
+ # @return [String]
74
+ def self.run_search(backend:, query:, after:, before:, recurrence: nil, limit:)
75
+ # Refuse a match-everything call: with no query and no date bound this
76
+ # would dump every cached event. "What's on my calendar" should name a
77
+ # span — steer the model to add after/before (e.g. this week). A lone
78
+ # recurrence filter is still too broad to stand in for a span.
79
+ if [query, after, before].all? { |v| v.to_s.strip.empty? }
80
+ return 'Error: give a query and/or a date range (after/before) to search for'
81
+ end
82
+
83
+ limit = limit.to_i.clamp(1, MAX_LIMIT)
84
+ recurring = { 'recurring' => true, 'single' => false }[recurrence]
85
+ # Fetch one past the cap so a full page is distinguishable from an
86
+ # exhausted calendar (see {MailSearch.run_search}); limit+1 back means
87
+ # more exist and {header} says so, rather than let the model read a
88
+ # full page as the whole calendar.
89
+ hits = backend.search(query:, limit: limit + 1, recurring:,
90
+ after: DateHelpers.parse_after(after), before: DateHelpers.parse_before(before))
91
+ return offline_or_no_match(backend, query) if hits.empty?
92
+
93
+ has_more = hits.size > limit
94
+ hits = hits.first(limit)
95
+ [header(shown: hits.size, has_more:), *hits.map { |h| render(h) }].join("\n\n")
96
+ rescue ArgumentError => e
97
+ "Error: bad date filter (#{e.message})."
98
+ end
99
+
100
+ # The leading line: affirm completeness when every event fit ("all
101
+ # shown"), or — when capped — name the two levers that get the rest.
102
+ # Adding query words is *not* one (words are OR-matched, so they widen);
103
+ # mirrors {MailSearch.header}, the recall-first principle riding the
104
+ # prompt snippet ({Extension::INBOUND_USAGE}).
105
+ #
106
+ # @param shown [Integer] events actually rendered.
107
+ # @param has_more [Boolean] whether more matched than fit.
108
+ # @return [String]
109
+ def self.header(shown:, has_more:)
110
+ return "#{shown} event#{shown == 1 ? '' : 's'} (all shown):" unless has_more
111
+
112
+ "Top #{shown} events — more exist. To see them, raise limit (up to " \
113
+ "#{MAX_LIMIT}) or add an after/before date range. Adding query words " \
114
+ 'matches more events (any word), so it will not narrow.'
115
+ end
116
+ private_class_method :header
117
+
118
+ # Distinguish "no events cached at all" (fixable) from "no match". An
119
+ # empty query is a date-range listing, so its no-match line names the
120
+ # range rather than an empty query string.
121
+ #
122
+ # @return [String]
123
+ def self.offline_or_no_match(backend, query)
124
+ return OFFLINE_HINT if backend.event_count.zero?
125
+
126
+ query.to_s.strip.empty? ? 'No events found in that date range.' : "No matching events found for #{query.inspect}."
127
+ end
128
+ private_class_method :offline_or_no_match
129
+
130
+ # @return [String]
131
+ def self.render(event)
132
+ loc = event[:location].to_s.strip
133
+ tail = +''
134
+ tail << " | where: #{loc}" unless loc.empty?
135
+ tail << ' | recurring' if event[:recurring]
136
+ <<~EV.chomp
137
+ #{event[:title].to_s.strip.empty? ? '(untitled)' : event[:title].strip}
138
+ when: #{DateHelpers.when_label(event)}#{tail}
139
+ id: #{event[:event_id]}
140
+ EV
141
+ end
142
+ private_class_method :render
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sqlite3'
4
+
5
+ module Pikuri
6
+ module Thunderbird
7
+ # The mechanical outbound guard for {MailCompose} — deterministic, never an
8
+ # AI verdict. The compose window is a strong human barrier on the *visible*
9
+ # (the recipient, gross body content) but soft on the *invisible* (control
10
+ # bytes, look-alike domains, subtle body-exfil), so the machine covers what
11
+ # the eye can't. It runs *before* {MailtoUri} encodes anything.
12
+ #
13
+ # ComposeGuard.new(backend: contacts).check(to: 'a@acme.com', body: 'Hi')
14
+ # # => #<Verdict ok=true, to=["a@acme.com"], notes=[...]>
15
+ #
16
+ # Two tiers:
17
+ #
18
+ # * *Hard reject* — a suspicious **body** fails closed (nothing is handed
19
+ # off; the human retypes in Thunderbird), because an agent-authored
20
+ # outbound body has no honest reason to carry control/bidi/zero-width/
21
+ # homoglyph characters. A suspicious **subject or address** is dropped
22
+ # (omitted from the draft) for the human to retype — addresses additionally
23
+ # must be ASCII-only and non-punycode (an +xn--+ domain is a pre-encoded
24
+ # homoglyph that would otherwise pass {Pikuri::Sanitizer}'s mixed-script
25
+ # check as pure ASCII).
26
+ # * *Soft warn* — the correspondence-graph novelty check: a recipient domain
27
+ # the user has never exchanged mail with (per {Gloda::Contacts#domains_seen}) stays
28
+ # in the draft but earns a loud note urging out-of-band verification. A
29
+ # hard reject here would fire on every genuinely-new correspondent. A body
30
+ # carrying an *encoded-looking blob* (a long base64, hex or base32 run —
31
+ # see {#body_blob_note}) likewise stays but earns a located note.
32
+ #
33
+ # == Why per-field address omission
34
+ #
35
+ # If any address in a field (To/Cc/Bcc) fails the hard checks, the *whole*
36
+ # field is dropped, not just the bad address — "the recipient is the attack,"
37
+ # so forcing a full retype of a tainted field beats silently keeping the
38
+ # addresses that happened to pass beside it.
39
+ #
40
+ # Immutable.
41
+ class ComposeGuard
42
+ LOGGER = Pikuri.logger_for('Thunderbird::ComposeGuard')
43
+
44
+ # Shortest token the blob spotter sums toward a body warning. Below 16 the
45
+ # three-class test gets flaky and honest engineering tokens (short SHAs,
46
+ # build IDs) creep in — and since the sum is body-wide, that means alarm
47
+ # fatigue. See {#body_blob_note}.
48
+ BLOB_TOKEN_MIN = 16
49
+
50
+ # Joined blob-token length at/above which the body earns the encoded-blob
51
+ # note. A 32-byte key is 44 base64 chars, comfortably over. See {#body_blob_note}.
52
+ BLOB_JOIN_MIN = 32
53
+
54
+ # The standard base64 alphabet. Excludes base64url's +-+/+_+ on purpose:
55
+ # those collide with hyphenated labels and +snake_case+ identifiers common
56
+ # in honest mail, whereas +++ +/+ +=+ never appear in prose tokens. Cost:
57
+ # base64url/JWT-shaped payloads slip by — accepted, per {#body_blob_note}.
58
+ BLOB_ALPHABET = %r{\A[A-Za-z0-9+/=]+\z}
59
+
60
+ # Single-case hex — what a key already wears on disk. Mixed case is absent
61
+ # on purpose: that is three-class, so {BLOB_ALPHABET} has it. No interior
62
+ # +-+, so a dashed UUID matches nothing here.
63
+ HEX_ALPHABET = /\A(?:[a-f0-9]+|[A-F0-9]+)\z/
64
+
65
+ # RFC-4648 base32; single-case like {HEX_ALPHABET}, so it shares the
66
+ # longer thresholds below.
67
+ BASE32_ALPHABET = /\A[A-Z2-7]+=*\z/
68
+
69
+ # {BLOB_TOKEN_MIN}'s twin for the single-alphabet encodings, doubled: a
70
+ # one-alphabet run is likelier to be an honest identifier than a base64
71
+ # run is, so it must be twice as long to count.
72
+ SINGLE_CLASS_TOKEN_MIN = 32
73
+
74
+ # {BLOB_JOIN_MIN}'s twin, in the only gap available: above a full git
75
+ # SHA-1 (40), below a 32-byte key in base32 (52) or hex (64). Accepts one
76
+ # irreducible false positive — a lone SHA-256 checksum warns, since a
77
+ # 256-bit hash and a 256-bit key are the same string (+D_blob_spotter_alphabets+).
78
+ SINGLE_CLASS_JOIN_MIN = 48
79
+
80
+ # The outcome of checking one compose request.
81
+ #
82
+ # * +ok+ — +false+ only when a body flag forced a fail-closed abort.
83
+ # * +error+ — the fail-closed message ({String}), present iff +ok+ is false.
84
+ # * +to+ / +cc+ / +bcc+ — {Array}<{String}> addresses that passed the hard
85
+ # checks (rejected fields come back empty).
86
+ # * +subject+ — the {String} subject, or +nil+ (absent, or dropped on a flag).
87
+ # * +body+ — the {String} body when +ok+ (guaranteed clean), else +nil+.
88
+ # * +notes+ — {Array}<{String}> human-facing warnings (dropped fields +
89
+ # novelty) for the observation.
90
+ Verdict = Data.define(:ok, :error, :to, :cc, :bcc, :subject, :body, :notes)
91
+
92
+ # @param backend [Gloda::Contacts, nil] contact resolver for the novelty
93
+ # check; +nil+ disables it (the verdict then carries a "couldn't check"
94
+ # note).
95
+ # @return [ComposeGuard]
96
+ def initialize(backend: nil)
97
+ @backend = backend
98
+ end
99
+
100
+ # Check one compose request and return a {Verdict}.
101
+ #
102
+ # @param to [String] recipient address(es), comma-separated.
103
+ # @param cc [String, nil] Cc address(es), comma-separated.
104
+ # @param bcc [String, nil] Bcc address(es), comma-separated.
105
+ # @param subject [String, nil] subject line.
106
+ # @param body [String] plain-text body.
107
+ # @return [Verdict]
108
+ def check(to:, body:, cc: nil, bcc: nil, subject: nil)
109
+ body_res = Pikuri::Sanitizer.sanitize(body.to_s)
110
+ unless body_res.warnings.empty?
111
+ return Verdict.new(ok: false, error: body_fail_message(body_res),
112
+ to: [], cc: [], bcc: [], subject: nil, body: nil, notes: [])
113
+ end
114
+
115
+ notes = []
116
+ kept_to = check_addresses('To', to, notes)
117
+ kept_cc = check_addresses('Cc', cc, notes)
118
+ kept_bcc = check_addresses('Bcc', bcc, notes)
119
+ novelty_note(kept_to + kept_cc + kept_bcc, notes)
120
+ body_blob_note(body.to_s, notes)
121
+
122
+ Verdict.new(ok: true, error: nil, to: kept_to, cc: kept_cc, bcc: kept_bcc,
123
+ subject: check_subject(subject, notes),
124
+ body: body.to_s.empty? ? nil : body, notes: notes)
125
+ end
126
+
127
+ private
128
+
129
+ # @return [String] the fail-closed observation naming the flagged classes.
130
+ def body_fail_message(result)
131
+ kinds = result.warnings.map(&:kind).join(', ')
132
+ "won't compose this message — the body contains characters an outbound " \
133
+ "mail has no honest reason to carry (#{kinds}). If this is genuinely " \
134
+ 'what you want to send, type it directly in Thunderbird.'
135
+ end
136
+
137
+ # @return [String, nil] the subject when clean, +nil+ when absent or dropped.
138
+ def check_subject(subject, notes)
139
+ return nil if subject.nil? || subject.to_s.empty?
140
+
141
+ warnings = Pikuri::Sanitizer.sanitize(subject).warnings
142
+ return subject if warnings.empty?
143
+
144
+ notes << "Subject omitted (suspicious characters: #{warnings.map(&:kind).join(', ')}) — " \
145
+ 'type it in the compose window.'
146
+ nil
147
+ end
148
+
149
+ # Split a comma-separated address field and keep it only if *every*
150
+ # address passes the hard checks; otherwise drop the whole field.
151
+ #
152
+ # @return [Array<String>] the vetted addresses, or +[]+ if dropped/absent.
153
+ def check_addresses(label, field, notes)
154
+ return [] if field.nil? || field.to_s.strip.empty?
155
+
156
+ addrs = field.to_s.split(',').map(&:strip).reject(&:empty?)
157
+ bad = addrs.reject { |a| address_ok?(a) }
158
+ return addrs if bad.empty?
159
+
160
+ notes << "#{label} omitted (couldn't safely include #{bad.map(&:inspect).join(', ')}) — " \
161
+ 'type the recipient(s) in the compose window.'
162
+ []
163
+ end
164
+
165
+ # @return [Boolean] whether one address is safe to pre-fill: ASCII-only,
166
+ # no +xn--+ punycode, and clean under {Pikuri::Sanitizer}.
167
+ def address_ok?(addr)
168
+ return false unless addr.ascii_only?
169
+ return false if addr.downcase.include?('xn--')
170
+
171
+ Pikuri::Sanitizer.sanitize(addr).warnings.empty?
172
+ end
173
+
174
+ # Append the correspondence-graph novelty warning for any recipient domain
175
+ # the user has no prior mail with. Best-effort: degrades to a "couldn't
176
+ # check" note when the backend is absent or its identity schema differs
177
+ # from the probed shape (the schema is unverified across TB versions).
178
+ #
179
+ # @return [void]
180
+ def novelty_note(addrs, notes)
181
+ domains = addrs.map { |a| a.split('@', 2).last.to_s.downcase }.reject(&:empty?).uniq
182
+ return if domains.empty?
183
+
184
+ unless @backend
185
+ notes << "Couldn't check whether you've mailed these recipients before " \
186
+ '(mail index unavailable) — double-check the address is correct.'
187
+ return
188
+ end
189
+
190
+ seen = @backend.domains_seen(domains)
191
+ novel = domains - seen
192
+ return if novel.empty?
193
+
194
+ notes << "You have no prior mail with #{novel.join(', ')}. If you expected a " \
195
+ 'familiar contact, watch for a look-alike domain and verify out of ' \
196
+ 'band (not by replying to a suspect message).'
197
+ rescue SQLite3::SQLException => e
198
+ LOGGER.warn("recipient-novelty check unavailable: #{e.message}")
199
+ notes << "Couldn't check whether you've mailed these recipients before — " \
200
+ 'double-check the address is correct.'
201
+ end
202
+
203
+ # Soft-warn when the body carries an encoded-looking blob: an encoded dump
204
+ # of a secret is the easiest high-bandwidth exfil, all-printable-ASCII so
205
+ # it passes the {Pikuri::Sanitizer} gate while the human skims past the
206
+ # gibberish. A flagged body still composes — the note only aims the eye:
207
+ #
208
+ # body_blob_note("ship it:\naGVsbG8gd29ybGQgT3BlbkFJc2VjcmV0", notes)
209
+ # # appends: "Line 2 onward contains encoded-looking content
210
+ # # (aGVsbG8gd29ybGQgT3BlbkFJ…, 32 chars total) — verify you
211
+ # # meant to send this; an encoded blob is how a secret leaks
212
+ # # past a skim."
213
+ #
214
+ # @param body [String] the plain-text body (already {Pikuri::Sanitizer}-clean).
215
+ # @param notes [Array<String>] accumulator, appended to in place.
216
+ # @return [void]
217
+ #
218
+ # == Implementation details
219
+ #
220
+ # The two chunk shapes ({#blob_kind}) carry independent thresholds and the
221
+ # body warns on whichever trips first. Summing body-wide rather than over
222
+ # one contiguous run folds space-split and word-interleaved payloads back
223
+ # together for free: cover words fail both gates and drop out.
224
+ #
225
+ # A paste / dumb-attack guard, *not* an exfil control — a prompt-injected
226
+ # model can add "split it into 15-char pieces" for free and walk under
227
+ # every threshold here. Hence soft-warn, and the minimums kept high to stay
228
+ # quiet on engineering mail; the irreversible-send compose window is the
229
+ # real backstop, so err toward not warning. Don't recast this as "closes
230
+ # the encoded-blob channel."
231
+ def body_blob_note(body, notes)
232
+ hits = blob_hits(body)
233
+ [[:mixed, BLOB_JOIN_MIN], [:single, SINGLE_CLASS_JOIN_MIN]].each do |kind, join_min|
234
+ matched = hits.select { |hit| hit[:kind] == kind }
235
+ total = matched.sum { |hit| hit[:token].length }
236
+ next if total < join_min
237
+
238
+ first = matched.first
239
+ notes << "Line #{first[:line]} onward contains encoded-looking content " \
240
+ "(#{first[:token][0, 24]}…, #{total} chars total) — verify you meant " \
241
+ 'to send this; an encoded blob is how a secret leaks past a skim.'
242
+ break
243
+ end
244
+ end
245
+
246
+ # @param body [String] the plain-text body.
247
+ # @return [Array<Hash>] +{line: Integer, token: String, kind: Symbol}+ per
248
+ # blob-shaped token, in reading order.
249
+ def blob_hits(body)
250
+ body.split("\n").each_with_index.flat_map do |line, i|
251
+ line.split(/\s+/).filter_map do |raw|
252
+ tok = raw.gsub(%r{\A[^A-Za-z0-9+/=]+|[^A-Za-z0-9+/=]+\z}, '')
253
+ kind = blob_kind(tok)
254
+ { line: i + 1, token: tok, kind: kind } if kind
255
+ end
256
+ end
257
+ end
258
+
259
+ # @param tok [String] a whitespace-delimited token, sentence-punctuation stripped.
260
+ # @return [Symbol, nil] +:mixed+ for >= {BLOB_TOKEN_MIN} chars of
261
+ # {BLOB_ALPHABET} carrying upper *and* lower *and* a digit — encoder
262
+ # churn no prose word (one script), Capitalized word (two), or
263
+ # +invoice20260722+ slug (two) shows; +:single+ for >=
264
+ # {SINGLE_CLASS_TOKEN_MIN} chars of {HEX_ALPHABET} or
265
+ # {BASE32_ALPHABET}; +nil+ when neither fits. The digit is required
266
+ # either way — it is what keeps an ALL-CAPS sentence out of
267
+ # {BASE32_ALPHABET} and a +deadbeef+-ish word out of {HEX_ALPHABET}.
268
+ def blob_kind(tok)
269
+ return nil unless tok.match?(/[0-9]/)
270
+
271
+ if tok.length >= BLOB_TOKEN_MIN && tok.match?(BLOB_ALPHABET) &&
272
+ tok.match?(/[A-Z]/) && tok.match?(/[a-z]/)
273
+ :mixed
274
+ elsif tok.length >= SINGLE_CLASS_TOKEN_MIN &&
275
+ (tok.match?(HEX_ALPHABET) || tok.match?(BASE32_ALPHABET))
276
+ :single
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Thunderbird
5
+ # The +thunderbird_contact_search+ tool — resolve a person's name (or a
6
+ # partial address) to the email address(es) the user has actually
7
+ # corresponded with, ranked by familiarity. Inbound-only (a pure read,
8
+ # no egress leg), registered whenever {Gloda} is present — like mail
9
+ # search, and independently useful ("what's Jon's address?").
10
+ #
11
+ # Its reason to exist is {MailCompose}: the user says "email Jon Snow a
12
+ # summary," the model has a *name* and compose needs an *address*. Rather
13
+ # than invent one (a mis-send / exfil risk) or stop to ask, resolve it
14
+ # against the user's own correspondence graph via {Gloda::Contacts#resolve}.
15
+ # It returns *ranked candidates and never auto-picks* — a look-alike
16
+ # sender of received mail can surface, so the human reviewing the To field
17
+ # stays the last line (same posture as {ComposeGuard}).
18
+ #
19
+ # Sharing: +P_shared_locked+ — no state of its own, and the {Gloda}
20
+ # backend it queries locks; see that class's +== Sharing+.
21
+ class ContactSearch < Pikuri::Tool
22
+ # @return [Integer] default / max candidates returned (a short
23
+ # disambiguation list, not a mailbox scan).
24
+ DEFAULT_LIMIT = 5
25
+ MAX_LIMIT = 15
26
+
27
+ # @return [String] opencode-shape description.
28
+ DESCRIPTION = <<~DESC
29
+ Resolve a person's name (or a partial address) to their email address(es), drawn from the user's own Thunderbird correspondence history.
30
+
31
+ Usage:
32
+ - Give a name ("Jon Snow") or a fragment ("jon", "acme.com") to find who the user has actually mailed or received mail from.
33
+ - Returns ranked candidates as Name <address> with a familiarity signal — addresses the user has sent mail to rank highest — and never picks one for you.
34
+ - Use it to fill a recipient before drafting mail, then confirm the address with the user: a look-alike sender can appear in received mail, so resolution alone is not proof.
35
+ - Reads Thunderbird's own local index only; it never connects to any server.
36
+ DESC
37
+
38
+ # @param backend [Gloda::Contacts] the contact resolver.
39
+ # @return [ContactSearch]
40
+ def initialize(backend:)
41
+ @backend = backend
42
+ super(
43
+ name: 'thunderbird_contact_search',
44
+ description: DESCRIPTION,
45
+ parameters: Parameters.build { |p|
46
+ p.required_string :query, 'A name or partial address to resolve, e.g. "Jon Snow".'
47
+ p.optional_integer :limit, "Max candidates (default #{DEFAULT_LIMIT}, max #{MAX_LIMIT}), e.g. 5."
48
+ },
49
+ execute: lambda { |query:, limit: DEFAULT_LIMIT|
50
+ ContactSearch.run_search(backend: @backend, query:, limit:)
51
+ },
52
+ trifecta_legs: Pikuri::Thunderbird::INBOUND_LEGS
53
+ )
54
+ end
55
+
56
+ # @return [String] formatted ranked candidates or a no-match line.
57
+ def self.run_search(backend:, query:, limit:)
58
+ limit = limit.to_i.clamp(1, MAX_LIMIT)
59
+ candidates = backend.resolve(query:, limit:)
60
+ return "No contacts found matching #{query.inspect}." if candidates.empty?
61
+
62
+ header = "#{candidates.size} contact#{candidates.size == 1 ? '' : 's'} (best first):"
63
+ [header, *candidates.map { |c| render(c) }].join("\n\n")
64
+ end
65
+
66
+ # @param c [Hash] one candidate from {Gloda::Contacts#resolve}.
67
+ # @return [String]
68
+ def self.render(c)
69
+ who = c[:name].to_s.empty? ? c[:address] : "#{c[:name]} <#{c[:address]}>"
70
+ <<~CAND.chomp
71
+ #{who}
72
+ #{familiarity(c)}
73
+ CAND
74
+ end
75
+ private_class_method :render
76
+
77
+ # @param c [Hash]
78
+ # @return [String] the one-line familiarity signal (Sent history is the
79
+ # established-contact tell; received-only asks for a check).
80
+ def self.familiarity(c)
81
+ if c[:sent].positive?
82
+ "you have sent mail to this address #{c[:sent]}× (#{c[:count]} appearances in all) — an established contact."
83
+ else
84
+ "appears only in received mail (#{c[:count]}×); you have never sent here, so verify the address before using it."
85
+ end
86
+ end
87
+ private_class_method :familiarity
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Pikuri
6
+ module Thunderbird
7
+ # A consistent, lock-free, on-disk copy of a live Thunderbird SQLite DB
8
+ # (Gloda or a calendar store), so pikuri never queries the file
9
+ # Thunderbird holds open. Thunderbird keeps its DBs locked
10
+ # (+locking_mode=EXCLUSIVE+), so the only live-file touch is a *bracketed*
11
+ # +cp --reflink=auto+; every actual query runs against the copy we own.
12
+ #
13
+ # snap = DatabaseSnapshot.new(source: profile.gloda_path, dir: cache_dir)
14
+ # snap.refresh # (re)copies iff the source generation moved
15
+ # run_query_against(snap.path)
16
+ # snap.generation # the source change-counter this copy is as-of
17
+ #
18
+ # == Why bracketed
19
+ #
20
+ # A reflink clone is atomic w.r.t. concurrent writes, but *file
21
+ # atomicity ≠ DB consistency*: a clone taken mid-transaction faithfully
22
+ # photographs a torn DB. So each copy is bracketed — read the change
23
+ # counter (header bytes 24–27) and check +-journal+ absence *before and
24
+ # after* the copy; a copy counts as settled only when the counter didn't
25
+ # move and no rollback journal was present at either edge. Otherwise the
26
+ # copy is discarded and retried after a short backoff.
27
+ #
28
+ # The validated change counter doubles as the *generation* stamp: a
29
+ # caller checks freshness by comparing {.change_counter} of the live
30
+ # source against {#generation}, and {#refresh} re-copies only when they
31
+ # differ. One signal drives both consistency and freshness.
32
+ #
33
+ # == Implementation details
34
+ #
35
+ # On-disk rather than an +:memory:+ load: the page cache keeps idle RAM ≈ 0
36
+ # for a multi-hundred-MB Gloda, which a memory copy would pin for the life
37
+ # of the process.
38
+ #
39
+ # The copy lands in a caller-owned 0700 dir on the *same filesystem* as
40
+ # the source (so reflink's copy-on-write extent share is available;
41
+ # +--reflink=auto+ degrades to a full copy elsewhere). It is
42
+ # {Pikuri::Finalizers}-reaped, so a normal exit removes it; a crash can
43
+ # strand it (re-derivable data). Only the main DB file is copied — for a
44
+ # WAL store that yields an as-of-last-checkpoint view (Thunderbird
45
+ # checkpoints aggressively), which callers open with +?immutable=1+.
46
+ class DatabaseSnapshot
47
+ LOGGER = Pikuri.logger_for('Thunderbird::DatabaseSnapshot')
48
+
49
+ # Raised when a settled copy can't be taken within the attempt budget
50
+ # (Thunderbird mid-sync, counter won't sit still). Recoverable — the
51
+ # calling tool degrades to a stale snapshot or an +"Error: …"+.
52
+ class TornError < StandardError; end
53
+
54
+ # @return [String] absolute path of the snapshot copy.
55
+ attr_reader :path
56
+
57
+ # @return [Integer, nil] the source change-counter this copy reflects;
58
+ # +nil+ before the first successful {#refresh}.
59
+ attr_reader :generation
60
+
61
+ # SQLite's file change counter: 4 big-endian bytes at header offset 24,
62
+ # bumped only when the DB is unlocked after a modification (so a read
63
+ # never moves it — a file-format guarantee). +nil+ if the file is too
64
+ # short to have a header yet.
65
+ #
66
+ # @param db_path [String]
67
+ # @return [Integer, nil]
68
+ def self.change_counter(db_path)
69
+ header = File.binread(db_path, 4, 24)
70
+ header && header.bytesize == 4 ? header.unpack1('N') : nil
71
+ rescue Errno::ENOENT, EOFError
72
+ nil
73
+ end
74
+
75
+ # @param source [String] path to the live Thunderbird DB.
76
+ # @param dir [String] private dir to hold the copy (created 0700 if
77
+ # absent); must sit on the same filesystem as +source+ for reflink.
78
+ # @return [DatabaseSnapshot]
79
+ def initialize(source:, dir:)
80
+ @source = source
81
+ @dir = dir
82
+ @path = File.join(dir, File.basename(source))
83
+ @generation = nil
84
+ FileUtils.mkdir_p(@dir, mode: 0o700)
85
+ # Fire-and-forget reap of the whole private dir (block form).
86
+ Pikuri::Finalizers.register { FileUtils.rm_rf(@dir) }
87
+ end
88
+
89
+ # (Re)take the snapshot iff the live source generation differs from the
90
+ # copy we hold — a no-op (returning +false+) when already current.
91
+ #
92
+ # @param attempts [Integer] bracket retries before giving up.
93
+ # @param backoff [Float] seconds between retries.
94
+ # @return [Boolean] +true+ if a fresh copy was taken this call.
95
+ # @raise [TornError] if no settled copy could be taken.
96
+ def refresh(attempts: 6, backoff: 0.08)
97
+ live = self.class.change_counter(@source)
98
+ return false if @generation && live == @generation && File.file?(@path)
99
+
100
+ copy_bracketed(attempts, backoff)
101
+ true
102
+ end
103
+
104
+ private
105
+
106
+ # @return [void]
107
+ # @raise [TornError]
108
+ def copy_bracketed(attempts, backoff)
109
+ detail = nil
110
+ attempts.times do
111
+ before_counter = self.class.change_counter(@source)
112
+ before_journal = journal?(@source)
113
+ copy_file!
114
+ after_counter = self.class.change_counter(@source)
115
+ after_journal = journal?(@source)
116
+
117
+ if settled?(before_counter, after_counter, before_journal, after_journal)
118
+ @generation = after_counter
119
+ return
120
+ end
121
+
122
+ detail = "counter #{before_counter}->#{after_counter}, " \
123
+ "journal #{before_journal}/#{after_journal}"
124
+ sleep(backoff)
125
+ end
126
+ raise TornError, "no settled snapshot of #{@source} after #{attempts} tries (#{detail})"
127
+ end
128
+
129
+ # Settled iff the counter is present and unchanged across the copy and
130
+ # no rollback journal existed at either edge.
131
+ #
132
+ # @return [Boolean]
133
+ def settled?(before_counter, after_counter, before_journal, after_journal)
134
+ !before_counter.nil? && before_counter == after_counter &&
135
+ !before_journal && !after_journal
136
+ end
137
+
138
+ # @return [Boolean] whether a +<source>-journal+ (write in flight) exists.
139
+ def journal?(db_path) = File.exist?("#{db_path}-journal")
140
+
141
+ # reflink-when-possible copy through the Subprocess chokepoint.
142
+ #
143
+ # @return [void]
144
+ # @raise [TornError] if +cp+ fails.
145
+ def copy_file!
146
+ result = Pikuri::Subprocess.spawn(
147
+ 'cp', '--reflink=auto', '-f', @source, @path, chdir: '/'
148
+ ).wait
149
+ return if result.status.success?
150
+
151
+ raise TornError, "cp failed copying #{@source}: #{result.output.strip}"
152
+ end
153
+ end
154
+ end
155
+ end