These 50 commits are when the Protocol Buffers files have changed:
| Commit: | 3130565 | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | Ludovico Russo | |
fix(delivery): bound the retry path with a time-based Retry Budget Three faults in the retry path were reported together, and two turned out not to be what the report said. ADR 0007 records the reasoning; the vocabulary is in CONTEXT.md. A Delivery that Kannon never got an answer for is now **Failed**, not Bounced and not Rejected: a Bounce always carries a reply code, because a Bounce is a remote mail system having spoken, so StatsDataFailed deliberately carries a `reason` and no `code`. The wire slot has existed, unemitted and unmapped, since the proto was written, so nothing is wire-breaking and no migration is needed — the Stats worker consumes the `kannon.stats.*` wildcard and `stats.type` is a varchar. The **Retry Budget** replaces `envelope.maxRetry = 10` with a span of time, defaulting to 24h from the moment the Batch asked for the Delivery to be sent. One predicate on the entity, `Delivery.CanRetry`, now governs every retry in the system, and both paths that hand a Delivery back to the Pool meet at one chokepoint that either reschedules it or ends it as Failed. A span is indifferent to *what* consumed the attempts, so an outage of Kannon's own cannot spend a sender's chances of delivery; under DefaultBackoff a 24h window admits exactly the retries the constant admitted (17h04m in, 34h08m out), which is pinned by test rather than asserted in a comment. ExponentialBackoff still needs no maximum: the window makes the overflow unreachable by construction. **Reclaim** builds the half of ADR 0004 that was never built. A new `claimed_at` column and a partial index let each claiming worker recover its own stranded rows — the Dispatcher what it claimed for dispatch past one hour, the Validator what it claimed for validation past five minutes. The threshold is measured from the claim and never from `scheduled_time`: the claim does not move that column, so under a backlog it is hours in the past on a row claimed one second ago, and reading it would reset live sends and duplicate them in bulk. A dispatch reclaim spends a send attempt, which is what makes a systematically stranding condition converge on termination through the Retry Budget; a validation reclaim does not, being no send attempt at all. A reclaim recovers and never terminates — only the Retry Budget ends a Delivery. The `permanent` flag was not broken. #433 re-specified it to follow the SMTP reply class on both the synchronous and asynchronous paths, which is what the code already did, so the reported fix would now be a regression; the line that reads like a bug says why it is not, and a unit test on the synchronous leg pins it. Fixes #378
The documentation is generated from this commit.
| Commit: | 107583b | |
|---|---|---|
| Author: | Ludovico Russo | |
fix(delivery): bound the retry path with a time-based Retry Budget Three faults in the retry path were reported together, and two turned out not to be what the report said. ADR 0007 records the reasoning; the vocabulary is in CONTEXT.md. A Delivery that Kannon never got an answer for is now **Failed**, not Bounced and not Rejected: a Bounce always carries a reply code, because a Bounce is a remote mail system having spoken, so StatsDataFailed deliberately carries a `reason` and no `code`. The wire slot has existed, unemitted and unmapped, since the proto was written, so nothing is wire-breaking and no migration is needed — the Stats worker consumes the `kannon.stats.*` wildcard and `stats.type` is a varchar. The **Retry Budget** replaces `envelope.maxRetry = 10` with a span of time, defaulting to 24h from the moment the Batch asked for the Delivery to be sent. One predicate on the entity, `Delivery.CanRetry`, now governs every retry in the system, and both paths that hand a Delivery back to the Pool meet at one chokepoint that either reschedules it or ends it as Failed. A span is indifferent to *what* consumed the attempts, so an outage of Kannon's own cannot spend a sender's chances of delivery; under DefaultBackoff a 24h window admits exactly the retries the constant admitted (17h04m in, 34h08m out), which is pinned by test rather than asserted in a comment. ExponentialBackoff still needs no maximum: the window makes the overflow unreachable by construction. **Reclaim** builds the half of ADR 0004 that was never built. A new `claimed_at` column and a partial index let each claiming worker recover its own stranded rows — the Dispatcher what it claimed for dispatch past one hour, the Validator what it claimed for validation past five minutes. The threshold is measured from the claim and never from `scheduled_time`: the claim does not move that column, so under a backlog it is hours in the past on a row claimed one second ago, and reading it would reset live sends and duplicate them in bulk. A dispatch reclaim spends a send attempt, which is what makes a systematically stranding condition converge on termination through the Retry Budget; a validation reclaim does not, being no send attempt at all. A reclaim recovers and never terminates — only the Retry Budget ends a Delivery. The `permanent` flag was not broken. #433 re-specified it to follow the SMTP reply class on both the synchronous and asynchronous paths, which is what the code already did, so the reported fix would now be a regression; the line that reads like a bug says why it is not, and a unit test on the synchronous leg pins it. Fixes #378
The documentation is generated from this commit.
| Commit: | 7899cff | |
|---|---|---|
| Author: | Ludovico Russo | |
refactor(tracking): tighten the sentinel chokepoints after review Four things the review surfaced. The mint and the Stats worker disagreed about one address. The mint accepted any non-empty local part under track.<domain> as a pseudonymous identity, including the Anonymous sentinel — which the Stats worker then reads as naming nobody and Terms as an upstream bug. IsPseudonym and NamesNobody now partition the namespace between them, so an identity the mint accepts is one the write path will record. Unreachable from today's Builder, but the whole point of a chokepoint is that it does not depend on its callers. namesNobody moved out of pkg/stats and next to the sentinels it reasons about, where it also picks up the case folding the rest of the namespace checks already do. IdentifiesRecipient and IsolatesRecipient were the same four lines with a different rung; both now read one scale through Mode.reaches, so they cannot drift into disagreeing about what silence means. Docs: the pseudonymous rung and the track.<domain> reservation are now in docs/UPGRADING.md, where an operator will actually meet them, and the wire comment on TRACKING_MODE_PSEUDONYMOUS no longer says the Mode is refused. Also: retained() takes a claimedIdentity rather than a claimedEmail, since under Pseudonymous it is not one; the test helpers the pseudonymous tests had copied are now shared; and the aggregate counter under Pseudonymous is pinned rather than inferred.
| Commit: | 9d8b9f4 | |
|---|---|---|
| Author: | Ludovico Russo | |
feat(tracking): implement the pseudonymous Tracking Mode The Pseudonymous rung was defined and ranked in CONTEXT.md but reserved — refused at every API boundary — pending the per-Batch identifier decision. ADR 0006 is that decision, and it is included here: the token envelope is untouched and the identity claim becomes a sentinel address under the Modes that name nobody. Identified, Full the recipient address, as today Pseudonymous <rand>@track.<sending-domain> Anonymous anonymous@track.<sending-domain>, never a stat row Because every identity stays email-shaped, the Stats worker, the stats schema and the API surface need no change: a pseudonymous event flows through the existing per-recipient write path and the Mode discriminates. The pseudonym is 128 bits from crypto/rand as a lowercase hex local part, drawn once per Delivery in the Builder so the pixel and every link of that Delivery share it — that intra-Delivery coherence is what makes a Recipient's events linkable within the Batch. It is regenerated every Batch, stored nowhere and derived from nothing, so nobody, Kannon included, can link it across Batches or back to an address. track.<domain> is a reserved namespace enforced at the one chokepoint every token passes through: statssec refuses a pseudonymous mint whose identity does not sit under it, so a caller passing the real address with a pseudonymous Mode is caught rather than shipped. Operators must not deliver mail under that subdomain. Mode gains IsolatesRecipient, one rung below IdentifiesRecipient, which answers the three questions that must not drift apart: whether the mint draws a per-Delivery identity, whether the token may be shared across the Batch, and whether the Tracker keeps the claim. Closes #424, and #411 is answered by the rung rather than by cryptography.
| Commit: | 97c95e1 | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
feat(mailer): carry a one-click unsubscribe endpoint (RFC 8058) (#429) A caller may now state its own unsubscribe endpoint per Batch, emitted as List-Unsubscribe + List-Unsubscribe-Post on every Delivery. The endpoint belongs to the sender: Kannon personalises the URL, emits it and signs it, and does nothing else with it — it never calls the endpoint, keeps no suppression list, and records no engagement when a recipient uses it. The URL travels in a header, so the click rewriter never sees it. Only one-click https is supported. Naming the field one_click_unsubscribe makes the assertion about the sender's endpoint — that it answers POST with `List-Unsubscribe=One-Click` and needs no confirmation — impossible to make by accident, which a boolean beside a neutral URL would not. Stated at Batch level only. A Domain-wide default would stamp an unsubscribe on the password resets and receipts that are this sender's core traffic. The URL is a template: placeholders resolve from the Recipient's fields plus `email`, which is now injected as the Recipient's address unless the caller passes a field of that name itself. Values substituted into the URL are percent-encoded — unlike in the body, where the context of a placeholder is undecidable. A Recipient whose fields leave a placeholder unresolved is Rejected at intake with `unsubscribe_url_unresolved`, and the Builder omits the headers rather than ship an authenticated one-click that cannot work. DKIM now signs a fixed header set — From, To, Cc, Subject, Message-ID and the two List-* headers — whether or not each is present, with the List-* names given twice. Signing an absent header is what lets a receiver detect one inserted in transit (RFC 6376 §5.4); doubling extends that to a message that already carries it, where instances are matched bottom-up and an injected copy would otherwise stay unsigned while being the one a client reads first. internal/dkim/oversign_test.go verifies the resulting signatures rather than just their h= tag. No migration: the declaration is a key in the existing headers JSONB column. See docs/adr/0005-one-click-unsubscribe-and-signed-header-set.md.
| Commit: | 6a5c1d2 | |
|---|---|---|
| Author: | Ludovico Russo | |
feat(mailer): carry a one-click unsubscribe endpoint (RFC 8058) A caller may now state its own unsubscribe endpoint per Batch, emitted as List-Unsubscribe + List-Unsubscribe-Post on every Delivery. The endpoint belongs to the sender: Kannon personalises the URL, emits it and signs it, and does nothing else with it — it never calls the endpoint, keeps no suppression list, and records no engagement when a recipient uses it. The URL travels in a header, so the click rewriter never sees it. Only one-click https is supported. Naming the field one_click_unsubscribe makes the assertion about the sender's endpoint — that it answers POST with `List-Unsubscribe=One-Click` and needs no confirmation — impossible to make by accident, which a boolean beside a neutral URL would not. Stated at Batch level only. A Domain-wide default would stamp an unsubscribe on the password resets and receipts that are this sender's core traffic. The URL is a template: placeholders resolve from the Recipient's fields plus `email`, which is now injected as the Recipient's address unless the caller passes a field of that name itself. Values substituted into the URL are percent-encoded — unlike in the body, where the context of a placeholder is undecidable. A Recipient whose fields leave a placeholder unresolved is Rejected at intake with `unsubscribe_url_unresolved`, and the Builder omits the headers rather than ship an authenticated one-click that cannot work. DKIM now signs a fixed header set — From, To, Cc, Subject, Message-ID and the two List-* headers — whether or not each is present, with the List-* names given twice. Signing an absent header is what lets a receiver detect one inserted in transit (RFC 6376 §5.4); doubling extends that to a message that already carries it, where instances are matched bottom-up and an injected copy would otherwise stay unsigned while being the one a client reads first. internal/dkim/oversign_test.go verifies the resulting signatures rather than just their h= tag. No migration: the declaration is a key in the existing headers JSONB column. See docs/adr/0005-one-click-unsubscribe-and-signed-header-set.md.
| Commit: | 1de5695 | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
feat!: tracking Policy: per-Domain/Batch/Recipient control over open and link tracking (#420) * docs: ADR 0002/0003 and CONTEXT.md vocabulary for Tracking Policy Records the two governing decisions for the Tracking Policy work (#413): 0002 fixes Kannon as an executor of tracking instructions rather than a store of consent; 0003 fixes the ceiling semantics, the defaults, and intake resolution. CONTEXT.md gains Tracking Mode and Tracking Policy and extends the Rejected outcome to cover an over-ceiling Recipient. Refs #413 * feat(tracking): add Tracking Mode, Policy and cascade resolution Introduce the internal/tracking module that owns the Tracking Mode / Tracking Policy vocabulary from CONTEXT.md: the ordered scale of Modes, the pair of Modes making up a Policy, the rank order, and the function collapsing the Domain, Batch and Recipient statements into the one effective Policy for a Delivery. The rank order is plain Go and deliberately not the protobuf enum number, so a future Mode can be inserted mid-scale without renumbering the wire. The module depends on nothing so it can sit below batch, delivery and envelope without a cycle. Nothing consumes it yet. Refs #414 * feat(db): add tracking column to domains, messages and pool One jsonb column named tracking on each of the three levels that state a Tracking Policy. The Domain and the Delivery default to identified: that single statement backfills existing rows and defaults new ones, which for an existing installation is a reduction from today's effectively-full behaviour (ADR 0003). The Batch defaults to an empty object because it is the only level allowed to state nothing, and is kept as provenance. Refs #414 * feat(domains): carry the Tracking Policy on the Domain entity Map the new tracking columns onto internal/tracking.Policy through sqlc, and give the Domain entity its Policy plus the repository operation that replaces it. The starting value stays the column default, so identified is stated in one place only. An unstated Mode is normalised to off before it is persisted, so the empty string never appears at rest on a Domain: a ceiling that states nothing would be unenforceable. That is the one invariant only visible at the repository boundary, so the Domain repository spec asserts it. Refs #414 * feat(adminapi): set and read back a Domain's Tracking Policy Add SetTrackingPolicy and expose the current Policy on the Domain message, so an operator can configure their ceiling and verify it without inspecting the database. A generic UpdateDomain was rejected: the Domain message exposes only the FQDN and the DKIM public key, and a general-purpose mutation would invite unrelated fields along. TrackingMode and TrackingPolicy live in a shared proto package because the mailer protos will state the same types, and duplicating the enum on the wire would let the two drift. internal/trackingpb is the only place that knows both the wire enums and the domain types, which keeps internal/tracking free of protobuf and means every boundary accepting a Policy refuses the same values. Pseudonymous is reserved and not implemented, so selecting it fails with Unimplemented rather than being quietly treated as something else. Refs #414 * feat(delivery): freeze the resolved Tracking Policy on the Delivery The Delivery gains its Tracking Policy as carried state, following the params pattern the Backoff policy already uses: the Domain/Batch/Recipient cascade is resolved once, at intake, and the concrete result is frozen here (ADR 0003). A Delivery therefore records the Policy that actually governed it rather than whatever is configured when someone later looks. New normalises the Policy so a fresh Delivery — and the Pool row it becomes — can never hold a Mode that states nothing. Load keeps what storage holds, which is concrete both by that rule and by the column default. Refs #415 * feat(envelope): honour the Delivery's frozen Tracking Policy The Builder reads the Policy frozen on the Delivery and applies it as it stands: it does not re-resolve the cascade and does not consult configuration. Under Off for opens no pixel is injected, under Off for links no href is rewritten, and under Off for both the delivered message carries no stats.<domain> hostname at all. The two axes are independent in both directions. A Mode that states nothing imposes no restriction, so every Mode other than Off keeps the behaviour it had before — the anonymous/full distinctions are separate work. preparedHTML now takes the Delivery and its SendingData instead of six positional strings, since it needs the Policy as well. Refs #415 * feat(db): round-trip the Delivery's Tracking Policy through the Pool CreatePool now states the tracking column explicitly on the CopyFrom path, and a rehydrated Delivery carries the stored Policy back out. The Builder reads that value on the dispatch path, so a Policy lost in storage would silently change what is tracked. pgx's jsonb codec handles tracking.Policy on the binary CopyFrom path as it already does for CustomFields, so no driver.Valuer/sql.Scanner is needed on the type. The repository spec pins both halves: the frozen Policy comes back unchanged, and a Delivery scheduled without a stated Policy is stored concrete — sending_pool_emails.tracking never holds an unstated Mode. Refs #415 * feat(mailapi): resolve the Tracking Policy at intake The Mailer API resolves the cascade when the Batch is created and writes the concrete result onto every Delivery, so a Batch stays internally consistent even if an operator changes the Domain Policy mid-flight, and the Pool never holds a Mode that states nothing (ADR 0003). Only the Domain participates for now: it is passed as the ceiling and the Batch and Recipient levels state nothing, which imposes no restriction of their own. The call is made per Delivery so the two later levels slot into the same resolution rather than restructuring it. Refs #415 * test(e2e): a Domain set to off sends untracked mail The counterpart of the Opened and Clicked subtests: set both axes of a Domain's Tracking Policy to off through the Admin API, send, recover the delivered message from the sender mock and assert it carries no pixel, no rewritten href and no tracking hostname. The Policy is set before the send because it is resolved at intake, which is also what the subtest documents. The other subtests are untouched — the default is identified, so they must and do still see tracking. Refs #415 * feat(statssec): sign the resolved Tracking Mode into open and link tokens The Tracker cannot look the Mode up when an engagement arrives: Pool rows are deleted on terminal outcomes, so by then there may be no Delivery row left. Mint the Mode as a claim instead, on both the open and the link token, so a signature fixes what may be observed about a request at send time. Signing is what makes it trustworthy: a recipient can neither escalate their own tracking nor suppress it to skew a sender's statistics. A token minted before the claim existed carries none, which states nothing and so never reaches Full. Refs #416 * feat(envelope): mint tracking tokens carrying the Delivery's frozen Mode The Builder already reads the Policy frozen on the Delivery to decide whether to inject the pixel and rewrite links. Pass the Mode of each axis into the token it belongs to as well — the opens Mode on the open token, the links Mode on the link token — so the Tracker acts on the Policy that governed this Delivery rather than on whatever is configured when the engagement arrives. Refs #416 * feat(stats): carry the resolved Tracking Mode on the stat event An Opened with no ip and no user_agent does not say why they are absent. Add the Mode that governed the engagement channel to the Stats message, reusing the shared pkg.kannon.tracking.types.TrackingMode enum, so a consumer can tell a Mode that forbade retaining them from a request that merely lacked them. trackingpb gains FromMode for the boundaries that state one Mode rather than a whole Policy: a stat event describes one channel, so it carries one Mode. Refs #416 * feat(tracker): retain IP and user agent only under full The Tracker reads the Tracking Mode from the verified token claims and captures the IP address and user agent from the request only under Full. Under Identified — the default, so this is the ordinary case — the event carries the Recipient identity and neither field, and the stat row persisted from it therefore contains neither. The Mode also travels on the event, so a consumer need not infer the case from absent fields. Log lines report what was retained rather than what the request happened to carry. The package had no tests. It gains them, driven through the real handlers against the real statssec service: a stub deciding for itself which tokens are acceptable could not tell a forged token from a legitimate one, and forgery is exactly what the signature is there to stop. A Mode claim rewritten in place, or re-signed with a key the server does not know, is refused and publishes no stat. Refs #416 * feat(tracking): compare a stated Policy against its ceiling (#417) CeilingViolations reports, per axis, when a stated Tracking Mode collects more than a ceiling Policy allows (ADR 0003: a lower level may only restrict, never widen). A Mode that states nothing on either side never violates, and the two axes are evaluated independently. Pure and reusable: the Mailer API will use it to fail a Batch that asks above its Domain's ceiling (#417), and the Recipient level will reuse the same function per Recipient (#419). * feat(mailer): accept an optional Tracking Policy on send requests (#417) Adds pkg.kannon.tracking.types.TrackingPolicy tracking = 10 to both SendHTMLReq and SendTemplateReq, reusing the shared wire types from #415. Omitting the field states nothing; wiring it into request handling and Batch resolution follows in the next commit. Regenerated with buf generate; buf lint stays clean for these files (the two pre-existing hz.proto warnings are unrelated to this change). * feat(mailapi): resolve the Batch level and enforce its Domain ceiling (#417) The Batch now carries the caller's stated Tracking Policy end to end: - batch.New/Load gain a Tracking Policy param, persisted verbatim as provenance on messages.tracking via CreateMessage (sqlc regenerated) — the one place an unstated Mode may be stored, so it is never normalised. - The Mailer API translates the wire Policy with trackingpb.ToPolicy, rejecting pseudonymous as CodeUnimplemented and an unknown Mode as CodeInvalidArgument (mirrors adminapi.trackingPolicyError). - A Batch asking above its Domain's ceiling fails the call with CodeInvalidArgument naming every violating axis, the ceiling, and what was asked for, checked via tracking.CeilingViolations before the Batch is persisted. - The Batch now participates in intake resolution as the middle argument of tracking.Resolve, between the Domain and the (still unstated) Recipient. - SendHTML forwards Tracking onto the SendTemplateReq it builds internally, so it does not silently drop a caller's Policy. Omitting the field keeps behaviour unchanged: states nothing, imposes no restriction, resolves to the Domain value. * test(e2e): identified retains no IP or user agent, full retains both Two new subtests set the Domain Policy through the Admin API and assert both axes end to end: Identified attributes the Opened and Clicked events to the Recipient and keeps neither the IP address nor the user agent, Full keeps both. The existing Opened and Clicked subtests change behaviour with them. They run on a fresh Domain, which now resolves to Identified, so they no longer see an IP or a user agent — the reduction this PRD is for — and they now assert that absence. Refs #416 * test(e2e): a Batch above its Domain's ceiling fails the send call (#417) Adds the ceiling counterpart of the existing TrackingOff subtest: with the Domain set to Off on both axes, a Batch asking for Full on opens fails SendHTML with CodeInvalidArgument naming the violating axis, instead of being silently clamped (ADR 0003). clientTest.SendEmailExpectingFailure factors out the harness idiom for asserting an intake rejection, mirroring SendEmail's success path. * test(tracker): assert on what the recipient observes, not the raw response Returning the recorder's status and Location keeps the assertions in terms of what a recipient sees and drops a response body nobody reads. Refs #416 * docs(context): state what an engagement event retains under each Mode Refs #416 * feat(mailer): give the send response a per-Recipient rejection channel (#364) SendRes could only say that a Batch had been created, not what had become of the Recipients in it. A caller submitting a thousand addresses got a Batch id and a 200 even when every one of them had been dropped, so the data looked queued and was in fact gone. Add accepted_count, rejected_count and rejected_recipients, each refusal carrying a stable machine-readable reason token. The reasons are part of the API contract, so the set this build emits is documented on the field itself, where a caller will look for it. Proto only; the Mailer API starts populating the fields next. * feat(mailer): accept an optional Tracking Policy on a Recipient (#419) The Recipient is the most specific level of the Tracking Policy cascade, and the one that matters most: consent belongs to the recipient (ADR 0002), so a Batch of N Recipients will routinely contain a mix that a Batch-level flag cannot express. Field 3 on Recipient, optional, so omitting it states nothing and existing callers are untouched. The comment states the asymmetry a caller has to know about — an `off` here beats a Domain-level `full`, a `full` here does not beat a Domain-level `off` — and points at where the resulting rejection is reported. Proto only; resolution follows. * feat(mailapi): resolve the Recipient level and report intake rejections (#419, #364) Completes the Tracking Policy cascade at intake and closes the silent data-loss hole the completion would otherwise have widened. The two are one change: the Recipient level needs somewhere to report a refusal, and the only refusals reported so far went to a slog.Warn. Per Recipient, scheduleBatch now translates the stated Policy, checks it against the Domain's ceiling, and resolves the three levels into the concrete Policy frozen on the Delivery. A Recipient's ceiling is the Domain's Policy, not the Batch's: narrowing what the Batch asked for is ordinary resolution, while asking above the Domain is an instruction the operator never authorised. Consent may narrow, never widen (ADR 0003). Exceeding it Rejects the one Recipient rather than failing the call, so one bad row does not lose a send of thousands — the one place the Recipient level differs in consequence from the Batch level, which does fail the call. A Recipient stating the reserved `pseudonymous` Mode is refused the same way. Every path that used to drop a Recipient silently now reports it: invalid_email empty address, or refused by delivery.New tracking_above_ceiling asks for more than the Domain allows unsupported_tracking_mode a Mode this build will not act on A send in which every Recipient is refused now returns those refusals with accepted_count zero, instead of an empty success. Tests assert what a caller can observe — the response, and the Policy frozen on each Delivery — not which function ran. internal/tracking already covers both precedence cases as unit tests, so nothing is duplicated there. * test(e2e): a mixed Batch honours each Recipient's own Tracking Policy (#419) One send, three Recipients, three outcomes, all asserted from outside: the Recipient that states nothing is tracked at its Domain's level and its pixel produces an attributed open; the Recipient that states `off` receives a message with no pixel, no rewritten link and no tracking hostname in it; the Recipient that asks above the Domain's ceiling is Rejected with a reason in the send response and is never delivered to. This is the case the feature exists for — a caller honouring the consent state in its own CRM within a single send — and the only level at which the per-Delivery freeze is observable. SendEmail gains SendEmailResponse alongside it, since asserting on the intake outcome needs the whole response and not just the Batch id. * fix(tracking): read an unrecognised Mode as off, never as silence Resolve and the ceiling comparison both skipped a Mode with no position on the scale, which conflated two different things: ModeUnspecified, which states nothing and must defer to the level above, and a value this build cannot read, which was therefore also deferring. A domains.tracking row written by a newer build then imposed no ceiling at all, so any Batch Mode was permitted — the opposite of ADR 0003, where the Domain's Policy is the only guarantee an operator has. Policy.Normalized() already read the same input as off, so the two disagreed. One rule now covers both sites: silence defers, an unreadable value collects nothing. A ceiling that cannot be read still enforces the floor, and the reported violation keeps the Modes as configured so an operator sees the value that needs fixing. Refs #413 * feat(tracking): mint no Recipient identity under a Mode that does not identify Anonymous is the only Mode above Off that processes no personal data, and so the only one covered by the aggregate-statistics carve-out from the consent requirement. "Retains nothing per-recipient" therefore has to be true in fact, which starts at the mint: a token issued under a Mode below Identified now carries no email address at all. The drop happens where the claim is assembled rather than in the caller, so no caller can mint an anonymous token that names somebody by forgetting to blank the address first. Its consequence is that the token becomes a function of the Batch rather than the Delivery, which is what lets it be shared. Mode.IdentifiesRecipient states the line as a rank comparison, like every other question about the scale. A Mode with no position on it identifies: an unstated Mode imposes no restriction of its own, so a token minted before the Mode became a claim keeps the attribution it was minted to carry. Refs #418 * perf(envelope): issue one shared token per Batch when it names nobody A token that names no Recipient commits only to its Batch, its Domain, the tracked URL and the Mode, so every Delivery of a Batch can carry the very same one. Reuse rather than re-minting is what the privacy property actually needs: a JWT carries iat and exp, so two independent mints of identical claims are two distinguishable tokens, and two Recipients holding distinguishable tokens can be told apart even while neither is named. It is also where the cost of Anonymous collapses. On a Batch of 100k Deliveries with ten links this is eleven RSA-4096 signatures instead of roughly 1.1M. The Dispatcher builds across many Batches over a process lifetime, so the cache is bounded: two generations of a fixed size, rotated whole rather than tracked per entry, because a Dispatcher working through the Deliveries of a Batch is strongly clustered in time. The key names every boundary reuse must stop at, so a token cannot leak across Batches, Domains, URLs or Modes. Scoping the cache to a single dispatch pass would need no bound at all, but a pass claims at most a page of Deliveries — a large Batch would still sign thousands of times and the property would be lost. Refs #418 * feat(tracker): publish anonymous engagement events naming nobody The token an anonymous engagement arrives on already carries no address, but the Tracker is where the event is composed and so is the last gate before it reaches a consumer. Dropping the identity here as well means even a token minted by an older build cannot put an address on an anonymous event. retained now answers the whole question an engagement event poses — who it came from, and what the request revealed — in one place, so the two handlers cannot drift on one half of it. IP and user agent were already gated on Full and are unchanged. Refs #418 * feat(stats): write no per-recipient row for an anonymous event Anonymous is counted in aggregate only, so the per-recipient consumer writes nothing for it. The aggregated consumer is untouched: it reads only the Domain, the timestamp and the type, so it counts every Mode alike and the Domain keeps its open and click rates. Splitting the NATS subject so the two consumers no longer overlap was rejected — both subscribe to the single-token wildcard kannon.stats.*, which matches no longer subject, so the split would silently take the aggregate path down too. The invariant is asserted rather than assumed: an event that is not anonymous yet arrives naming nobody is logged as an error, not dropped in silence. In a compliance path a loud failure beats a quietly lost row. It is Termed and not Nak'd, because the fault is in the message itself and redelivering it could only reproduce the hot loop #396 fixed. trackingpb gains ToMode, the lenient read side of FromMode: a consumer's job is to handle what arrived, not to relitigate whether it should have, so unlike a Mode a client states this one is read rather than refused. Refs #418 * test(e2e): anonymous keeps its aggregate rates and retains nothing per-recipient One Batch to two Recipients, because both halves of the claim are about the pair. The tokens the two receive must be the same token — two independently minted ones would carry different iat/exp and so tell the two apart while naming neither — and after both are exercised the Domain's aggregate counters must have moved with no per-recipient engagement row anywhere. The absence is checked over a window rather than once: the two consumers are independent subscriptions, so a row arriving late would be just as much of a leak as one arriving at once. Refs #418 * docs(context): state what an anonymous engagement event leaves behind An engagement event under Anonymous is now the one outcome that does not become a stat row, which the Outcomes preamble asserted without exception. Opened gains the same, plus the invariant that an event which is not Anonymous yet names nobody is logged rather than discarded, and the Anonymous rung of the scale gains the shared token that follows from it naming nobody. Refs #418 * refactor(api): name the two tracking-policy error mappings apart Both the Admin API and the Mailer API translated the trackingpb sentinels onto Connect codes in a function called trackingPolicyError, with different defaults — same name, different semantics in sibling packages, which is a trap for whoever edits one and assumes the other. The Mailer API's becomes sendTrackingPolicyError and handles ErrUnknownMode explicitly rather than through its default, so the shared half of the mapping is visible in both and documented as a pair on each side. The duplication is kept deliberately: the two boundaries agree on the two sentinels and differ on everything else, and a shared helper would have to pull Connect codes into the translation package to save four lines. Refs #413 * docs: record the Tracking Policy in the architecture and upgrade notes ARCHITECTURE.md enumerates every internal package, so internal/tracking and internal/trackingpb belonged there; the modules that now answer to a Policy say so too — the Builder honouring a frozen Policy, the Mode travelling as a signed claim, the Mailer API owning intake resolution and per-Recipient rejection, the Tracker gating IP and user agent on full, and the stats pair where anonymous counts without leaving a row. Adds docs/UPGRADING.md for what an existing installation wakes up to: the move from effectively full to identified, what stops being retained, and the SQL and Admin API call that put a Domain back — which PRD #413 asks for and release-please-generated CHANGELOG.md has no room for. Also ignores the agent worktree directory, which is tooling scratch space. Refs #413 * fix(statssec): bind each tracking token to the channel it was minted for Open and link tokens were interchangeable. Both carried the same audience, neither carried a channel claim, and the verifier checked neither — and the two claim shapes differ only by a URL field JSON parsing ignores when absent. A link token therefore verified cleanly as open claims and handed the Tracker the Mode governing *links*, so any Domain whose two axes differ had its more permissive axis apply to both endpoints: a Domain on 'opens=off, links=full' could be made to record an identified open, with the requester's IP, by replaying a link token against /o/. The token sits in the delivered email, so the actor is the recipient, a forward chain, or anything that logs URLs. Each channel now mints and demands its own audience. Tokens minted before the Mode became a claim carry one undifferentiated audience and stay in inboxes for up to three months, so they are still accepted — but only while they state no Mode, which this build never mints, and stating nothing they can only restrict what is retained. The exception expires with them. Also pins the signing method and requires an expiry, and folds the two verify paths into one so a check added for one channel cannot be forgotten for the other. Refs #413, #416 * fix(mailapi,db): enforce the ceiling on an unstated axis, obfuscate rejected addresses Two smaller findings from the security review of this branch. A Domain whose tracking row leaves an axis unstated had no ceiling at all on that axis: an unstated ceiling short-circuits to 'no violation' and resolution treats it as no restriction, so a Batch could state full and get it. Not reachable through the API — the column default states both axes and the write path normalises — but that made the invariant rest on the column default, the write path and the migration all holding at once. It is now enforced once, on read, so a row edited by hand enforces the floor instead of dissolving the ceiling. The intake rejection log wrote the recipient address verbatim, where the rest of the codebase obfuscates it, and a caller can drive one line per submitted recipient. Obfuscated in the log, still returned in full to the caller, which is its own input. Rejection reasons gain a type so they cannot be confused with the free-form operator detail beside them. Refs #413, #417, #419 * docs: correct what the glossary and upgrade notes promise Review found three places where the docs said more than the code does. CONTEXT.md claimed every outcome becomes a stat row on a NATS topic, which the new intake rejection does not: it happens before a Delivery exists, so it is reported in the send response instead. The Rejected entry and the state machine now distinguish the two causes on exactly that basis. The Anonymous entry claimed one shared token per Batch without qualification — true per Batch-and-URL pair, but a URL personalised per Recipient is a different URL and cannot share one. And 'axis' is pinned to the opens/links dimension, since the glossary was using it for the level dimension too. UPGRADING.md called SetTrackingPolicy 'the auditable route', which overstates it: the Admin API has no authentication and records nothing about who changed a Policy. Replaced with the deployment requirement that follows from that, and ADR 0003 now records the same limit on its own central claim, along with the fact that anonymous removes the only signal that would make counter inflation visible. Also drops a hardcoded version number for a release that has not been cut. Refs #413 * refactor(envelope): hold the token-sharing rule in one place The two token helpers had the same body, and the quartet of email, batch, domain and mode travelled through six signatures at five and six parameters each — preparedHTML had been cleaned up but the clump moved downstream. The quartet becomes trackTarget, and the decision to share is stated once for both channels, so a key and the decision to reuse it cannot drift apart: a key missing anything the token commits to would hand one Recipient's token to another. Also trims the sharing rationale to the invariants that constrain the code, and adds the e2e subtest #364 asks for where every Recipient of a batch is rejected — the mixed case was covered end to end, the all-rejected one only at the API seam. Records why the anonymous absence check polls by hand instead of using require.Never, which panics when its goroutine outlives the subtest. Refs #413, #418, #364
| Commit: | 8703de2 | |
|---|---|---|
| Author: | Ludovico Russo | |
feat(mailer): accept an optional Tracking Policy on a Recipient (#419) The Recipient is the most specific level of the Tracking Policy cascade, and the one that matters most: consent belongs to the recipient (ADR 0002), so a Batch of N Recipients will routinely contain a mix that a Batch-level flag cannot express. Field 3 on Recipient, optional, so omitting it states nothing and existing callers are untouched. The comment states the asymmetry a caller has to know about — an `off` here beats a Domain-level `full`, a `full` here does not beat a Domain-level `off` — and points at where the resulting rejection is reported. Proto only; resolution follows.
| Commit: | 5557254 | |
|---|---|---|
| Author: | Ludovico Russo | |
feat(mailer): give the send response a per-Recipient rejection channel (#364) SendRes could only say that a Batch had been created, not what had become of the Recipients in it. A caller submitting a thousand addresses got a Batch id and a 200 even when every one of them had been dropped, so the data looked queued and was in fact gone. Add accepted_count, rejected_count and rejected_recipients, each refusal carrying a stable machine-readable reason token. The reasons are part of the API contract, so the set this build emits is documented on the field itself, where a caller will look for it. Proto only; the Mailer API starts populating the fields next.
| Commit: | 07b312b | |
|---|---|---|
| Author: | Ludovico Russo | |
merge: tracker and stats distinguish identified from full (#416) # Conflicts: # e2e/e2e_test.go
| Commit: | 8e44c21 | |
|---|---|---|
| Author: | Ludovico Russo | |
feat(mailer): accept an optional Tracking Policy on send requests (#417) Adds pkg.kannon.tracking.types.TrackingPolicy tracking = 10 to both SendHTMLReq and SendTemplateReq, reusing the shared wire types from #415. Omitting the field states nothing; wiring it into request handling and Batch resolution follows in the next commit. Regenerated with buf generate; buf lint stays clean for these files (the two pre-existing hz.proto warnings are unrelated to this change).
| Commit: | d7e31c4 | |
|---|---|---|
| Author: | Ludovico Russo | |
feat(stats): carry the resolved Tracking Mode on the stat event An Opened with no ip and no user_agent does not say why they are absent. Add the Mode that governed the engagement channel to the Stats message, reusing the shared pkg.kannon.tracking.types.TrackingMode enum, so a consumer can tell a Mode that forbade retaining them from a request that merely lacked them. trackingpb gains FromMode for the boundaries that state one Mode rather than a whole Policy: a stat event describes one channel, so it carries one Mode. Refs #416
| Commit: | cc864ac | |
|---|---|---|
| Author: | Ludovico Russo | |
feat(adminapi): set and read back a Domain's Tracking Policy Add SetTrackingPolicy and expose the current Policy on the Domain message, so an operator can configure their ceiling and verify it without inspecting the database. A generic UpdateDomain was rejected: the Domain message exposes only the FQDN and the DKIM public key, and a general-purpose mutation would invite unrelated fields along. TrackingMode and TrackingPolicy live in a shared proto package because the mailer protos will state the same types, and duplicating the enum on the wire would let the two drift. internal/trackingpb is the only place that knows both the wire enums and the domain types, which keeps internal/tracking free of protobuf and means every boundary accepting a Policy refuses the same values. Pseudonymous is reserved and not implemented, so selecting it fails with Unimplemented rather than being quietly treated as something else. Refs #414
| Commit: | 9cd5f37 | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
feat: add stats cleanup in order to remove old data from database (#311) * feat: add stats cleanup in order to remove old data from database * refactor: organize stats in ddd approach * feat: add aggregated stats * feat: expose stats and add tests * refactor: improve code * fix: test potential issue * ci: fix go modules cache issue
| Commit: | 8125960 | |
|---|---|---|
| Author: | Ludovico Russo | |
refactor: improve code
| Commit: | a711435 | |
|---|---|---|
| Author: | Ludovico Russo | |
feat: expose stats and add tests
| Commit: | b0d9c4d | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
feat: add cc and to headers control in sending api (#305) * feat: add cc and to headers control in sending api * fix: missing CC could lead to error * refactor: improve and add tests * fix: linter
| Commit: | 0e7deb0 | |
|---|---|---|
| Author: | Ludovico Russo | |
feat: add cc and to headers control in sending api
| Commit: | c845188 | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
feat: refactor apikey handling (#302) * feat: implementing api key * feat: improve implementation and remove unsued stuff * feat: remove api key from domain * refactor: improve tests * feat: add tests for service with inmem repo * fix: minimum improvements * chore: update docker compose and makefile for easier test run * feat: generate api keys @ 64 len * feat: update lint * refactor: remove key field from domain and update related queries * fix: generalize domain in test * test: add test for preserving API key state on update error
| Commit: | 00a1f9c | |
|---|---|---|
| Author: | Ludovico Russo | |
fix: minimum improvements
| Commit: | 383975b | |
|---|---|---|
| Author: | Ludovico Russo | |
feat: remove api key from domain
| Commit: | 5518336 | |
|---|---|---|
| Author: | Ludovico Russo | |
feat: improve implementation and remove unsued stuff
| Commit: | 4f3bd0d | |
|---|---|---|
| Author: | Ludovico Russo | |
feat: implementing api key
| Commit: | 50f2b10 | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
feat: improve dependency container (#264) * refactor: implement generic singleton pattern with error handling - Introduced a generic singleton type to manage single instance creation with error handling. - Updated existing methods in the container to utilize the new MustGet method for safer initialization. - Added comprehensive unit tests for the singleton implementation, covering successful initialization, concurrent access, and type handling. * feat: implement container closers with timeout handling - Added a new Container struct with support for closers that can be executed with a timeout. - Implemented CloseWithTimeout method to handle parallel execution of closers and aggregate errors. - Introduced comprehensive unit tests for various scenarios including empty closers, successful closures, error aggregation, and timeout handling. - Ensured backward compatibility with the existing Close method while enhancing functionality. * docs: add comprehensive README for Container architecture * feat: add health check functionality to Container * feat: enhance E2E tests with HZ service integration - Removed PACKAGE_AFFINITY lint rule from buf.yaml. - Added HZ service client to clientTest and clientFactory in e2e/client_test.go. - Implemented test for HZ service in e2e/e2e_test.go, validating response structure. - Updated infrastructure test structure to include container management. - Enhanced container closing logic with context handling for graceful shutdowns. - Introduced HZ API service in pkg/api/api.go and updated server initialization to include it. * chore: upgrade deps * chore: upgrade golint-ci
| Commit: | f9d50be | |
|---|---|---|
| Author: | Ludovico Russo | |
feat: enhance E2E tests with HZ service integration - Removed PACKAGE_AFFINITY lint rule from buf.yaml. - Added HZ service client to clientTest and clientFactory in e2e/client_test.go. - Implemented test for HZ service in e2e/e2e_test.go, validating response structure. - Updated infrastructure test structure to include container management. - Enhanced container closing logic with context handling for graceful shutdowns. - Introduced HZ API service in pkg/api/api.go and updated server initialization to include it.
| Commit: | 05dcced | |
|---|---|---|
| Author: | Ludovico Russo | |
refactor: update module path from ludusrusso to kannon-email - Changed module path in go.mod, buf.gen.yaml, and various proto files to reflect the new repository location. - Updated import paths across the codebase to ensure consistency with the new module path. - Adjusted related configuration files to maintain compatibility with the updated structure.
| Commit: | 2703795 | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
feat: add global fields when sending emails (#236)
| Commit: | 78a1e5c | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
feat: add attachments to kannon (#211)
| Commit: | 3faf39b | |
|---|---|---|
| Author: | Ludovico Russo | |
feat: add stats aggregated
| Commit: | e412254 | |
|---|---|---|
| Author: | Ludovico Russo | |
feat: add get domain api
| Commit: | fbb66cd | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
wip: big refactoring, to test (#89) * wip: big refactoring, to test * fix linter * run verifier * fix lint
| Commit: | 951d2df | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
add stats api (#88)
| Commit: | 02c37ac | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
handle stats in a single table (#85)
| Commit: | 50c76fe | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
✨ add real templates (#82) * add real template * move template admin to admin api * rm deprecated stuff and fix some queries
| Commit: | f9e6b21 | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
✨ handle templating (#77) * add new fields to db and proto * replacing fields in html * fix some linting errors * fix some lints
| Commit: | f5d74d3 | |
|---|---|---|
| Author: | Ludovico Russo | |
fix: stats log ok
| Commit: | 0c48fda | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
update soft bounce (#75)
| Commit: | 0c2dfab | |
|---|---|---|
| Author: | Ludovico Russo | |
update soft bounce
| Commit: | 54c540a | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
✨ Add smtp server to handle soft bounces (#74) * fix: return path * wip * add smtp server to handle bounces * add configurations * fix: some linting issue
| Commit: | 5128818 | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | Ludovico Russo | |
fix: return path
| Commit: | 2049316 | |
|---|---|---|
| Author: | Ludovico Russo | |
add stats link
| Commit: | 7db5570 | |
|---|---|---|
| Author: | Ludovico Russo | |
add bounce
| Commit: | 81f7863 | |
|---|---|---|
| Author: | Ludovico Russo | |
add scheduled time
| Commit: | cd839e6 | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | Ludovico Russo | |
✨ Live 13 apr 2022 (#57) Member ludusrusso commented 8 minutes ago • What this PR does / why we need it: Add e2e test and improve code Code live on twitch.tv/ludusrusso fixes #58 * test: optmize db test * feat: add e2e test * feat: move dockerfile to go 1.18 * tool: add dependabot * fix: golang-ci action Co-authored-by: davideimola <davide.imola@icloud.com>
| Commit: | d657432 | |
|---|---|---|
| Author: | Ludovico Russo | |
feat: add e2e test
| Commit: | 35445dc | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
🛠️ using buf to handle proto generation
| Commit: | a10c750 | |
|---|---|---|
| Author: | arosso96 | |
| Committer: | GitHub | |
🐛 Renamed proto package to pb Co-Authored-by: Ludovico Russo <ludus.russo@gmail.com>
| Commit: | 47285b2 | |
|---|---|---|
| Author: | Ludovico Russo | |
| Committer: | GitHub | |
🥜 Add nats and jetstream support (#22) * 🥜 create dispatcher with nats support * add dispatcher * add message handler in sender * fix tests * generate messages when email is sent * add dispatcher to docker * update nats * add statistics with logs * use NATS_CONN in sender
| Commit: | eca0761 | |
|---|---|---|
| Author: | Ludovico Russo | |
up
| Commit: | 6886467 | |
|---|---|---|
| Author: | Ludovico Russo | |
up