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.
- checksums.yaml +7 -0
- data/DESIGN.md +460 -0
- data/README.md +66 -0
- data/lib/pikuri/thunderbird/calendar.rb +372 -0
- data/lib/pikuri/thunderbird/calendar_create.rb +126 -0
- data/lib/pikuri/thunderbird/calendar_read.rb +130 -0
- data/lib/pikuri/thunderbird/calendar_search.rb +145 -0
- data/lib/pikuri/thunderbird/compose_guard.rb +281 -0
- data/lib/pikuri/thunderbird/contact_search.rb +90 -0
- data/lib/pikuri/thunderbird/database_snapshot.rb +155 -0
- data/lib/pikuri/thunderbird/date_helpers.rb +93 -0
- data/lib/pikuri/thunderbird/extension.rb +196 -0
- data/lib/pikuri/thunderbird/gloda/contacts.rb +229 -0
- data/lib/pikuri/thunderbird/gloda/mail.rb +216 -0
- data/lib/pikuri/thunderbird/gloda.rb +153 -0
- data/lib/pikuri/thunderbird/ical_line.rb +130 -0
- data/lib/pikuri/thunderbird/ics_event.rb +131 -0
- data/lib/pikuri/thunderbird/launcher.rb +76 -0
- data/lib/pikuri/thunderbird/mail_compose.rb +92 -0
- data/lib/pikuri/thunderbird/mail_read.rb +73 -0
- data/lib/pikuri/thunderbird/mail_search.rb +131 -0
- data/lib/pikuri/thunderbird/mailto_uri.rb +58 -0
- data/lib/pikuri/thunderbird/profile.rb +201 -0
- data/lib/pikuri-thunderbird.rb +76 -0
- data/prompts/thunderbird.txt +5 -0
- metadata +114 -0
|
@@ -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
|