Skip to content

Commit c256215

Browse files
committed
feat: Add :admin-bcc option for notification subscribers
1 parent 9ed4907 commit c256215

7 files changed

Lines changed: 131 additions & 80 deletions

File tree

config.edn.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,11 @@
208208
;; :tls true
209209
;; :user "notify@example.com"
210210
;; :password "secret"
211-
;; :from "bark@example.com"}}
211+
;; :from "bark@example.com"}
212+
;; ;; Optional -- copy this address (or vector of
213+
;; ;; addresses) on every notification (subscriber
214+
;; ;; digest and direct-to-author failure mail).
215+
;; :admin-bcc "ops@example.com"}
212216

213217
;; ---- Logging -------------------------------------------------------------
214218

docs/bark-manual.org

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ urgent or important, and can still be closed.
584584
=Urgent= and =Important= require punctuation (=.= =,= =;= =:= =?=
585585
=!=) or end of line. A bare space is rejected, since words like
586586
"Important" appear too frequently in ordinary prose to be safe
587-
triggers.
587+
bareword annotations.
588588

589589
These two values combine into the numeric priority:
590590

@@ -1099,6 +1099,7 @@ limit with =:max-attachment-size= in the =:ingest= config section.
10991099
:password "secret"
11001100
:from "bark@example.com"
11011101
:reply-to "lead@example.com"} ;; optional
1102+
:admin-bcc "ops@example.com" ;; optional; string or vector
11021103
:subscribers
11031104
{"a@example.org"
11041105
[{:source "my-list"}]
@@ -1112,6 +1113,11 @@ associated vector lists their subscriptions, one per source --
11121113
a recipient on three sources receives three separate emails per
11131114
=bb notify= run.
11141115

1116+
=:admin-bcc= adds a hidden carbon-copy on every subscriber digest.
1117+
A single address as a string, or a vector of addresses. Useful to
1118+
audit deliverability and to catch bounces. The BCC field is not
1119+
visible to the other recipients (standard SMTP semantics).
1120+
11151121
Optional per-subscription filters:
11161122

11171123
| Key | Description |
@@ -1136,6 +1142,13 @@ records the last successful send per (subscriber, source) so the
11361142
same failed-command entries are not re-mailed at every run. Delete
11371143
the file to replay all current failures on the next invocation.
11381144

1145+
Command failures are kept on file for the subscriber digest and for
1146+
manual inspection via =bb maintenance --failures=. BARK does *not*
1147+
mail the author of a failing command directly: an unsubscribed user
1148+
who typed =Superseded-by:= with a typo learns of the failure only
1149+
if they are themselves on the subscriber list. Maintainers see all
1150+
failures with =:audience :maintainers= in their digest.
1151+
11391152
** Logging
11401153

11411154
#+begin_src edn

scripts/bark-notify.clj

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@
4343
;;
4444
;; We persist the timestamp of the last successful notification per
4545
;; (subscriber, source) pair to avoid re-sending the same failure on
46-
;; every run. This is operational state, not configuration -- subscriber
47-
;; identity and filters live in config.edn.
46+
;; every run. This is operational state, not configuration --
47+
;; subscribers and filters live in config.edn.
4848

4949
(def ^:private last-failures-file "public/.last-notify-failures.edn")
5050

@@ -238,8 +238,8 @@
238238
"\n"))))
239239

240240
(defn- join-sections
241-
"Concatenate the non-nil sections with blank-line separators and append
242-
a short footer. Returns nil when every section is nil."
241+
"Concatenate the non-nil sections with blank-line separators and
242+
append a short footer. Returns nil when every section is nil."
243243
[sections]
244244
(let [present (filter some? sections)]
245245
(when (seq present)
@@ -278,10 +278,24 @@
278278
;; SMTP
279279
;; ---------------------------------------------------------------------------
280280

281+
(defn- bcc-list
282+
"Normalize `:admin-bcc` to a vector of addresses (or nil when absent).
283+
Accepts a single string or a vector of strings."
284+
[admin-bcc]
285+
(cond
286+
(nil? admin-bcc) nil
287+
(string? admin-bcc) [admin-bcc]
288+
(sequential? admin-bcc) (vec admin-bcc)
289+
:else (do (log/warn "Ignoring invalid :admin-bcc"
290+
(pr-str admin-bcc))
291+
nil)))
292+
281293
(defn send-notification!
282-
"Send a plain-text notification email via SMTP."
283-
[smtp-config to-addr source body]
284-
(let [{:keys [host port tls user password from reply-to]} smtp-config]
294+
"Send a plain-text notification email via SMTP.
295+
`admin-bcc` is forwarded as :bcc on every send when non-nil."
296+
[smtp-config to-addr source body admin-bcc]
297+
(let [{:keys [host port tls user password from reply-to]} smtp-config
298+
bccs (bcc-list admin-bcc)]
285299
(mail/send-mail
286300
(cond-> {:host host
287301
:port port
@@ -292,7 +306,8 @@
292306
:to [to-addr]
293307
:subject (str "[BARK " source "] Open reports")
294308
:text body}
295-
reply-to (assoc :reply-to reply-to)))))
309+
reply-to (assoc :reply-to reply-to)
310+
(seq bccs) (assoc :bcc bccs)))))
296311

297312
;; ---------------------------------------------------------------------------
298313
;; Main
@@ -318,6 +333,7 @@
318333
dbp (db-path config)
319334
notif (:notifications config)
320335
smtp (:smtp notif)
336+
admin-bcc (:admin-bcc notif)
321337
subscribers (:subscribers notif)]
322338
(cond
323339
(not (and notif (:enabled notif)))
@@ -365,7 +381,7 @@
365381
"Notifying" email (str "(source: " source ")"))
366382
(when-not dry-run?
367383
(try
368-
(send-notification! smtp email source body)
384+
(send-notification! smtp email source body admin-bcc)
369385
(swap! updated-shown assoc k (.getTime (java.util.Date.)))
370386
(swap! sent inc)
371387
(catch Exception e

scripts/validate-config.clj

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,14 @@
155155
:opt-un [:smtp/tls :smtp/reply-to]))
156156
(s/def :notif/enabled boolean?)
157157

158+
;; Admin Bcc: single email or non-empty vector of emails, copied on
159+
;; every outgoing subscriber digest. Lets the operator audit
160+
;; deliverability without subscribing each admin on each source
161+
;; individually.
162+
(s/def :notif/admin-bcc
163+
(s/or :one ::email
164+
:many (s/coll-of ::email :kind vector? :min-count 1)))
165+
158166
;; Subscriber filters (all optional except :source)
159167
(s/def :sub/source ::non-blank-string)
160168
(s/def :sub/min-priority (s/and int? #(<= 0 % 3)))
@@ -169,7 +177,7 @@
169177
(s/coll-of :notif/subscription :kind vector? :min-count 1)))
170178

171179
(s/def :bark/notifications (s/keys :req-un [:notif/enabled]
172-
:opt-un [:notif/smtp :notif/subscribers]))
180+
:opt-un [:notif/smtp :notif/subscribers :notif/admin-bcc]))
173181

174182
;; Valid report type keywords -- derived from common/report-type-spec.
175183
(def valid-report-types common/report-type-keywords)

src/bark/commands.clj

Lines changed: 53 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -260,27 +260,23 @@
260260
:unset-expiry (-> acc (dissoc :expiry) (assoc :unexpiry? true))
261261
:set-topic (assoc acc :topic topic)
262262
:unset-topic (-> acc (dissoc :topic) (assoc :untopic? true))
263-
:set-superseded (cond-> acc
264-
:always (assoc :superseded-by target-message-id)
265-
:always (dissoc :unsuperseded-by? :unsuperseded-by-mid))
266-
:unset-superseded (cond-> acc
267-
:always (dissoc :superseded-by)
268-
:always (assoc :unsuperseded-by? true)
269-
target-message-id (assoc :unsuperseded-by-mid target-message-id))
270-
:set-supersedes (cond-> acc
271-
:always (assoc :supersedes target-message-id)
272-
:always (dissoc :unsupersedes? :unsupersedes-mid))
273-
:unset-supersedes (cond-> acc
274-
:always (dissoc :supersedes)
275-
:always (assoc :unsupersedes? true)
276-
target-message-id (assoc :unsupersedes-mid target-message-id))
277-
:set-duplicate (cond-> acc
278-
:always (assoc :duplicate-of target-message-id)
279-
:always (dissoc :unduplicate-of? :unduplicate-of-mid))
280-
:unset-duplicate (cond-> acc
281-
:always (dissoc :duplicate-of)
282-
:always (assoc :unduplicate-of? true)
283-
target-message-id (assoc :unduplicate-of-mid target-message-id))
263+
;; All relation actions carry a mid (the `:param :message-id`
264+
;; in the registry guarantees the parser captured one).
265+
:set-superseded (-> acc (assoc :superseded-by target-message-id)
266+
(dissoc :unsuperseded-by? :unsuperseded-by-mid))
267+
:unset-superseded (-> acc (dissoc :superseded-by)
268+
(assoc :unsuperseded-by? true
269+
:unsuperseded-by-mid target-message-id))
270+
:set-supersedes (-> acc (assoc :supersedes target-message-id)
271+
(dissoc :unsupersedes? :unsupersedes-mid))
272+
:unset-supersedes (-> acc (dissoc :supersedes)
273+
(assoc :unsupersedes? true
274+
:unsupersedes-mid target-message-id))
275+
:set-duplicate (-> acc (assoc :duplicate-of target-message-id)
276+
(dissoc :unduplicate-of? :unduplicate-of-mid))
277+
:unset-duplicate (-> acc (dissoc :duplicate-of)
278+
(assoc :unduplicate-of? true
279+
:unduplicate-of-mid target-message-id))
284280
:set-related (-> acc
285281
(update :related-to-set (fnil conj #{}) target-message-id)
286282
(update :related-to-unset (fnil disj #{}) target-message-id))
@@ -524,22 +520,25 @@
524520
supersedes unsupersedes? unsupersedes-mid
525521
duplicate-of unduplicate-of? unduplicate-of-mid
526522
related-to-set related-to-unset]} resolved]
527-
(str/join ", " (concat (map (fn [[attr addr]] (str (name attr) " -> " addr)) set)
528-
(map #(str "un-" (name %)) unset)
529-
(when deadline [(str "deadline " deadline)])
530-
(when undeadline? ["no deadline"])
531-
(when expiry [(str "expiry " expiry)])
532-
(when unexpiry? ["no expiry"])
533-
(when topic [(str "topic:" topic)])
534-
(when untopic? ["no topic"])
535-
(when superseded-by [(str "superseded-by:" superseded-by)])
536-
(when unsuperseded-by? [(str "not superseded-by:" unsuperseded-by-mid)])
537-
(when supersedes [(str "supersedes:" supersedes)])
538-
(when unsupersedes? [(str "not supersedes:" unsupersedes-mid)])
539-
(when duplicate-of [(str "duplicate-of:" duplicate-of)])
540-
(when unduplicate-of? [(str "not duplicate-of:" unduplicate-of-mid)])
541-
(map #(str "related-to:" %) related-to-set)
542-
(map #(str "not-related-to:" %) related-to-unset)))))
523+
(str/join
524+
", "
525+
(concat (map (fn [[attr addr]] (str (name attr) " -> " addr)) set)
526+
(map #(str "un-" (name %)) unset)
527+
(keep identity
528+
[(when deadline (str "deadline " deadline))
529+
(when undeadline? "no deadline")
530+
(when expiry (str "expiry " expiry))
531+
(when unexpiry? "no expiry")
532+
(when topic (str "topic:" topic))
533+
(when untopic? "no topic")
534+
(when superseded-by (str "superseded-by:" superseded-by))
535+
(when unsuperseded-by? (str "not superseded-by:" unsuperseded-by-mid))
536+
(when supersedes (str "supersedes:" supersedes))
537+
(when unsupersedes? (str "not supersedes:" unsupersedes-mid))
538+
(when duplicate-of (str "duplicate-of:" duplicate-of))
539+
(when unduplicate-of? (str "not duplicate-of:" unduplicate-of-mid))])
540+
(map #(str "related-to:" %) related-to-set)
541+
(map #(str "not-related-to:" %) related-to-unset)))))
543542

544543
(defn- setter-address
545544
"Return the address credited as the setter of `attr` on `current`.
@@ -847,24 +846,29 @@
847846
surfaced in the pull map so `scope-permits?` can
848847
resolve :setter-or-maintainer on the unset
849848
command in the open-report path."
849+
;; :setter-attr must be UNIQUE per row. Both :supersedes rows share
850+
;; the `:rel/supersedes` schema kind; if we reused `:rel/supersedes`
851+
;; as the pull-map key, `relation-setters-as-pull` would overwrite
852+
;; the from-setter with the to-setter on a chained report (one that
853+
;; supersedes X AND is superseded by Y), so the scope check on the
854+
;; `Not superseded-by:` unset would see the wrong setter. The
855+
;; suffix `-from` / `-to` records the row's role. These keys are
856+
;; internal to the pull map; nothing else reads them as schema attrs.
850857
[{:id :superseded-by :kind :supersedes :role :current-as-from
851858
:propagate :superseded :propagate-tgt true
852859
:syntax "Superseded-by"
853860
:mid-key :superseded-by :unset-key :unsuperseded-by? :unset-mid-key :unsuperseded-by-mid
854-
:unset-syntax "Not superseded-by"
855-
:setter-attr :rel/supersedes}
861+
:setter-attr :rel/supersedes-from}
856862
{:id :supersedes :kind :supersedes :role :current-as-to
857863
:propagate :superseded :propagate-tgt true
858864
:syntax "Supersedes"
859865
:mid-key :supersedes :unset-key :unsupersedes? :unset-mid-key :unsupersedes-mid
860-
:unset-syntax "Not supersedes"
861-
:setter-attr :rel/supersedes}
866+
:setter-attr :rel/supersedes-to}
862867
{:id :duplicate-of :kind :duplicates :role :current-as-from
863868
:propagate :canceled :propagate-tgt false
864869
:syntax "Duplicate-of"
865870
:mid-key :duplicate-of :unset-key :unduplicate-of? :unset-mid-key :unduplicate-of-mid
866-
:unset-syntax "Not duplicate-of"
867-
:setter-attr :rel/duplicates}])
871+
:setter-attr :rel/duplicates-from}])
868872

869873
(defn- compute-closure-rows
870874
"Enrich `closure-relation-rows` with per-row decisions derived from
@@ -1020,7 +1024,7 @@
10201024
(into []
10211025
(comp (filter #(= :current-as-from (:role %)))
10221026
(map #(select-keys % [:id :kind :role :unset-key :unset-mid-key
1023-
:unset-syntax :setter-attr])))
1027+
:setter-attr])))
10241028
closure-relation-rows))
10251029

10261030
(defn- try-unclosed!
@@ -1231,15 +1235,15 @@
12311235
word-result (cond-> (merge implicit body-words)
12321236
self-ack? (dissoc :report/acked)
12331237
:always (filter-words-by-scope overrides is-maint? fail-ctx))
1234-
line-id-ok? (cond
1235-
carrier-only? carrier-eligible-ids
1236-
no-carrier? (complement carrier-eligible-ids)
1237-
:else (constantly true))
1238+
keep-line? (case line-filter
1239+
:carrier-only #(contains? carrier-eligible-ids (:id %))
1240+
:no-carrier #(not (contains? carrier-eligible-ids (:id %)))
1241+
(constantly true))
12381242
lines (when body-text
12391243
(->> (detect-lines report-type body-text overrides
12401244
(:email/date-sent email)
12411245
(:line-patterns src-cmds))
1242-
(filter #(line-id-ok? (:id %)))
1246+
(filter keep-line?)
12431247
(remove (fn [d]
12441248
(and (= :set (:action d))
12451249
(= :report/acked (:attr d))

src/bark/commands/registry.clj

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,18 @@
5757
:syntax "Not closed"}
5858
;; Closure relations -- backed by :rel/supersedes / :rel/duplicates;
5959
;; :attr kept for registry shape.
60-
{:id :superseded-by :kind :trigger :action :set-superseded :attr :rel/supersedes :scope :user
60+
;; The relation unsets use a direction-suffixed :attr so that
61+
;; `scope-permits?` finds the right setter in the pull map built
62+
;; by `relation-setters-as-pull` -- a chained report shares the
63+
;; `:rel/supersedes` schema kind across both directions, so the
64+
;; pull-map key must distinguish them.
65+
{:id :superseded-by :kind :trigger :action :set-superseded :attr :rel/supersedes :scope :user
6166
:syntax "Superseded-by" :param :message-id :report-types #{:bug :patch :request}}
62-
{:id :unsuperseded-by :kind :trigger :action :unset-superseded :attr :rel/supersedes :scope :setter-or-maintainer
67+
{:id :unsuperseded-by :kind :trigger :action :unset-superseded :attr :rel/supersedes-from :scope :setter-or-maintainer
6368
:syntax "Not superseded-by" :param :message-id :report-types #{:bug :patch :request}}
64-
{:id :duplicate-of :kind :trigger :action :set-duplicate :attr :rel/duplicates :scope :user
69+
{:id :duplicate-of :kind :trigger :action :set-duplicate :attr :rel/duplicates :scope :user
6570
:syntax "Duplicate-of" :param :message-id :report-types #{:bug :patch :request}}
66-
{:id :unduplicate-of :kind :trigger :action :unset-duplicate :attr :rel/duplicates :scope :setter-or-maintainer
71+
{:id :unduplicate-of :kind :trigger :action :unset-duplicate :attr :rel/duplicates-from :scope :setter-or-maintainer
6772
:syntax "Not duplicate-of" :param :message-id :report-types #{:bug :patch :request}}
6873

6974
;; --- Annotations: property-set -------------------------------------------
@@ -90,9 +95,9 @@
9095
:syntax "No topic"}
9196
;; Supersedes -- inverse role of Superseded-by (posed on the replacement;
9297
;; current = :rel/to, target = :rel/from = the closed report).
93-
{:id :supersedes :kind :annotation :action :set-supersedes :attr :rel/supersedes :scope :user
98+
{:id :supersedes :kind :annotation :action :set-supersedes :attr :rel/supersedes :scope :user
9499
:syntax "Supersedes" :param :message-id :report-types #{:bug :patch :request}}
95-
{:id :unsupersedes :kind :annotation :action :unset-supersedes :attr :rel/supersedes :scope :setter-or-maintainer
100+
{:id :unsupersedes :kind :annotation :action :unset-supersedes :attr :rel/supersedes-to :scope :setter-or-maintainer
96101
:syntax "Not supersedes" :param :message-id :report-types #{:bug :patch :request}}
97102
;; Related-to -- neutral cross-reference (no closure, multi-target,
98103
;; symmetric canonicalised by :rel/id). Multi-target makes a clean
@@ -106,11 +111,11 @@
106111
;; Derived indexes
107112
;; ---------------------------------------------------------------------------
108113

109-
;; Semantic groupings
110-
(def trigger-commands (filterv #(= :trigger (:kind %)) commands))
111-
(def annotation-commands (filterv #(= :annotation (:kind %)) commands))
112-
113-
;; Syntactic groupings (derived from :words vs :syntax presence)
114+
;; Syntactic groupings (derived from :words vs :syntax presence).
115+
;; The semantic axis (`:kind :trigger` / `:annotation`) is metadata
116+
;; on each entry, not surfaced as a derived index -- nothing consumes
117+
;; the full lists today; if you need them, write the `filterv` at
118+
;; the call site.
114119
(def word-commands (filterv :words commands))
115120
(def line-commands (filterv :syntax commands))
116121

0 commit comments

Comments
 (0)