Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91d10a7650 | ||
|
|
91841174dd | ||
|
|
fbc9d4b9c4 | ||
|
|
50ab63ff13 | ||
|
|
5c87505820 | ||
|
|
e1ad2467d2 | ||
|
|
059949674d | ||
|
|
759d36e85d | ||
|
|
02a6502f12 | ||
|
|
66eed23f79 | ||
|
|
abd28f2e17 | ||
|
|
26f00c2f7f | ||
|
|
b004143ea7 | ||
|
|
f29b75e325 | ||
|
|
58bd26030c | ||
|
|
131570da01 | ||
|
|
ece5384739 | ||
|
|
3c54ac92e9 | ||
|
|
c6894e2a24 | ||
|
|
cdcb8754ad | ||
|
|
4440e14320 | ||
|
|
1afa250203 | ||
|
|
d0219def80 | ||
|
|
dc49f2d5eb | ||
|
|
2c23a794b9 | ||
|
|
943b4c32a5 | ||
|
|
c8d81944e7 | ||
|
|
5446286562 | ||
|
|
d6f4a807d0 | ||
|
|
baf99e79b6 | ||
|
|
81182ee8cf |
@@ -0,0 +1,92 @@
|
||||
# vantage-shared
|
||||
|
||||
The private Go module every Vantage service imports. Extracted from the
|
||||
`vantage` monorepo with its history.
|
||||
|
||||
```
|
||||
vantage-shared/
|
||||
├── grpc/pb/ # the agent↔control-plane wire types, hand-written
|
||||
├── grpc/codec/ # the JSON codec they travel over
|
||||
├── proto/vantage/v1/ # vantage.proto - documentation for the above
|
||||
├── mail/ # the one email system: transport + templates
|
||||
├── license/ # payload, sign, verify, trusted keys, plans
|
||||
├── models/ # Instance, User, Settings
|
||||
├── provision/ # slug rules, reserved names, instance/user creation
|
||||
├── backup/ # dump, restore, verify, manifest, fingerprint
|
||||
├── cryptobox/ # the single AES-256-GCM implementation
|
||||
├── indexes/ # users.email and instances.slug
|
||||
└── cmd/lkctl/ # issue and inspect licences by hand
|
||||
```
|
||||
|
||||
Module path is `gitea.hostxtra.co.uk/vantage/vantage-shared` - **lowercase
|
||||
`vantage`**, though the Gitea org is canonically `Vantage`. Gitea serves both
|
||||
spellings; Go module paths are case-sensitive strings, and two spellings would
|
||||
cache as two modules. Keep the lowercase one.
|
||||
|
||||
## Who depends on this, and when they find out
|
||||
|
||||
| Repository | Modules | A change here reaches them |
|
||||
| ---------------- | ---------------------------------------- | ---------------------------------------------------- |
|
||||
| `vantage` | `server`, `agent`, `vantagectl` | `server` at the next push to main after a pin bump; `agent` and `vantagectl` only at their next release tag |
|
||||
| `vantage-admin` | `server` | at the next push to main after a pin bump |
|
||||
| `vantage-site` | `server` | at the next push to main after a pin bump |
|
||||
|
||||
**Nothing bumps a pin automatically.** A fix here is live nowhere until each
|
||||
consumer's `go.mod` moves, and nothing in any of those repositories will remind
|
||||
you. That is the trade this split made: a service can no longer be shipped
|
||||
against a version of this module it was never built against, and in exchange the
|
||||
staleness is now silent rather than impossible.
|
||||
|
||||
Tag a release when you change anything: `git tag v0.2.0 && git push origin
|
||||
v0.2.0`. Consumers pin exact versions.
|
||||
|
||||
## `proto/` is documentation, not a generator input
|
||||
|
||||
`grpc/pb` is **hand-written** JSON-tagged structs over the codec in
|
||||
`grpc/codec`. Nothing generates them, and `vantage.proto` is not compiled by any
|
||||
build. It is the readable statement of the wire contract, and it lives in the
|
||||
same repository as the Go types precisely so that a message added to one can be
|
||||
added to the other **in the same commit** - that co-location is the only thing
|
||||
enforcing the match, so do not split them again.
|
||||
|
||||
There used to be two copies of `pb`, in the agent and the server, and they had
|
||||
already drifted: the agent's `UnimplementedVantageServer` was three methods
|
||||
stale and carried no `ReportWorkloads` at all. One package now serves both
|
||||
sides. The agent links the server half as dead code, which the linker drops.
|
||||
|
||||
**A wire change lands in three steps, in this order**: release this module, bump
|
||||
the pin in `vantage/server` (live at the next push to main), bump the pin in
|
||||
`vantage/agent` (live only at the next `agent/v*` tag). The control plane will
|
||||
be ahead of the fleet in between, which was true before too - it is just written
|
||||
down in two `go.mod` files now instead of implied by a shared directory.
|
||||
|
||||
## Things that mirror something outside this repository
|
||||
|
||||
These cannot be enforced by the compiler and must be changed by hand, in step
|
||||
with a file in another repository:
|
||||
|
||||
- **`backup.ciphertextFields`** names each collection's `*_enc` fields and
|
||||
mirrors `server/internal/models` in the `vantage` repository, which this
|
||||
module cannot import. Wrong field names fail **silently**: `verify`'s live
|
||||
probe finds no ciphertext and reports "this database stores no ciphertext
|
||||
yet", so the one gate that catches what a key fingerprint cannot becomes a
|
||||
no-op. `settings` is deliberately in neither that map nor
|
||||
`CiphertextCollections()` - its ESO read token is a SHA-256 hash, not
|
||||
ciphertext.
|
||||
- **`mail/templates/layout.html.tmpl`** carries the control plane's dark theme
|
||||
values as **literal hex**. Email clients support neither `var()` nor a
|
||||
reliable `prefers-color-scheme`, so the token indirection the four front ends
|
||||
use is not available here. Every colour in the email system is in that one
|
||||
file. The tokens it mirrors live in `vantage-site`.
|
||||
- **`provision`** is the single implementation of the slug rules that both the
|
||||
control plane and Vantage HQ depend on. It is the one place those two
|
||||
repositories must agree on behaviour, which is why it is here rather than
|
||||
copied - but it now also means a change to it is a release and two pin bumps.
|
||||
|
||||
`mail/render_test.go` renders every template and fails if one exists that no
|
||||
case covers. The templates are parsed in `init()`, so without that test a
|
||||
mistyped field is a boot-time panic in three services.
|
||||
|
||||
## Writing style
|
||||
|
||||
Never use em dashes (the long dash character) anywhere: code, comments, UI copy, docs, commit messages. Use a plain hyphen ` - `, a comma, a colon, or split the sentence instead.
|
||||
+2
-2
@@ -98,8 +98,8 @@ func indexMember(name string) string { return "indexes/" + name + ".json" }
|
||||
// Reader is an opened archive.
|
||||
//
|
||||
// Open extracts to a temporary directory rather than streaming, because gzip
|
||||
// offers no random access and the manifest — which carries the checksums every
|
||||
// other member is judged against — is written last. Verifying before writing a
|
||||
// offers no random access and the manifest - which carries the checksums every
|
||||
// other member is judged against - is written last. Verifying before writing a
|
||||
// single document to the target is worth one pass over local disk. This is why
|
||||
// the container image needs a /tmp.
|
||||
type Reader struct {
|
||||
|
||||
+2
-2
@@ -148,8 +148,8 @@ func dumpIndexes(ctx context.Context, w *Writer, db *mongo.Database, name string
|
||||
defer cur.Close(ctx)
|
||||
|
||||
// The specs are read as raw BSON and re-encoded as extended JSON, one
|
||||
// element per index, so key order and every option the server reported —
|
||||
// partialFilterExpression, collation, weights and the rest — survive
|
||||
// element per index, so key order and every option the server reported -
|
||||
// partialFilterExpression, collation, weights and the rest - survive
|
||||
// verbatim. Decoding into bson.M would lose compound key order, and
|
||||
// reconstructing an index from a hand-picked set of options would drop
|
||||
// whatever was not picked.
|
||||
|
||||
+4
-4
@@ -72,7 +72,7 @@ func (o RestoreOptions) warn(format string, args ...any) {
|
||||
// The order is fixed and every check that can refuse does so before the first
|
||||
// write: format, checksums (done by Open), key policy, then target inspection.
|
||||
// A restore that has begun writing and then fails leaves a partial database
|
||||
// which the next run refuses to touch, which is correct — the alternative is a
|
||||
// which the next run refuses to touch, which is correct - the alternative is a
|
||||
// silent merge, and merging two control planes reconciles nothing.
|
||||
func Restore(ctx context.Context, opt RestoreOptions) (RestoreResult, error) {
|
||||
m := opt.Archive.Manifest()
|
||||
@@ -302,14 +302,14 @@ func splitBSON(raw []byte) (bson.Raw, []byte, error) {
|
||||
// The specs are handed to the createIndexes command exactly as the source
|
||||
// server reported them, rather than reconstructed into a mongo.IndexModel from
|
||||
// a hand-picked set of options. Reconstruction dropped every option nobody had
|
||||
// thought to pick — partialFilterExpression above all, which this codebase
|
||||
// thought to pick - partialFilterExpression above all, which this codebase
|
||||
// relies on for partial unique indexes, and which replayed as a full unique
|
||||
// index fails on any real database. It also lost compound key order, which is
|
||||
// significant.
|
||||
//
|
||||
// A unique index that will not build means the restored data violates it, and
|
||||
// the unique indexes here — (instance_id, email), instance slug, settings
|
||||
// instance, the ESO token hash — are tenant-isolation properties rather than
|
||||
// the unique indexes here - (instance_id, email), instance slug, settings
|
||||
// instance, the ESO token hash - are tenant-isolation properties rather than
|
||||
// optimisations. That aborts. A non-unique index failing is a performance
|
||||
// problem and warns.
|
||||
func replayIndexes(ctx context.Context, opt RestoreOptions, coll *mongo.Collection, name string) (int, error) {
|
||||
|
||||
@@ -243,7 +243,7 @@ func TestRestoreAbortsWhenAUniqueIndexCannotBuild(t *testing.T) {
|
||||
|
||||
// Built by hand rather than dumped: two documents that collide on email
|
||||
// alongside an index specification declaring email unique. No live database
|
||||
// would let those coexist, which is exactly the point — this is the shape
|
||||
// would let those coexist, which is exactly the point - this is the shape
|
||||
// of a corrupted or hand-edited archive, and restore must refuse rather
|
||||
// than load the rows and leave the index missing.
|
||||
a, err := bson.Marshal(bson.M{"email": "a@example.com"})
|
||||
@@ -371,8 +371,8 @@ func TestIdIndexIsSkipped(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestRestoreReplaysPartialUniqueIndex is the regression guard for the defect
|
||||
// that made a restore abort on any real database: a partial unique index —
|
||||
// this codebase has them on workflow_steps and settings — replayed as a full
|
||||
// that made a restore abort on any real database: a partial unique index -
|
||||
// this codebase has them on workflow_steps and settings - replayed as a full
|
||||
// unique index hits duplicate keys, and a failing unique index is fatal.
|
||||
func TestRestoreReplaysPartialUniqueIndex(t *testing.T) {
|
||||
client, _ := testDB(t)
|
||||
|
||||
+7
-7
@@ -109,8 +109,8 @@ func probe(ctx context.Context, opt VerifyOptions, rep *VerifyReport) error {
|
||||
rep.ProbeDecrypted = true
|
||||
return nil
|
||||
}
|
||||
// No ciphertext anywhere is an ordinary state — a deployment that has
|
||||
// stored no secrets, keys or SSO configuration yet — and is not a failure.
|
||||
// No ciphertext anywhere is an ordinary state - a deployment that has
|
||||
// stored no secrets, keys or SSO configuration yet - and is not a failure.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -121,13 +121,13 @@ func probe(ctx context.Context, opt VerifyOptions, rep *VerifyReport) error {
|
||||
// This map MIRRORS BY HAND the bson tags in server/internal/models, which this
|
||||
// package cannot import: shared/ is a separate module and models is under
|
||||
// server/internal. It must change in the same commit as any rename of the
|
||||
// fields below — the same mirrored-constant hazard as web/lib/targets.ts and
|
||||
// fields below - the same mirrored-constant hazard as web/lib/targets.ts and
|
||||
// services.MaxWorkloadLogLines. The sources are:
|
||||
//
|
||||
// keys — models/key.go: private_key_enc, passphrase_enc
|
||||
// secrets — models/secret.go: encrypted_value
|
||||
// auth_providers — models/auth_provider.go: client_secret_enc
|
||||
// console_sessions — models/console_session.go: rdp_user_enc, rdp_pass_enc
|
||||
// keys - models/key.go: private_key_enc, passphrase_enc
|
||||
// secrets - models/secret.go: encrypted_value
|
||||
// auth_providers - models/auth_provider.go: client_secret_enc
|
||||
// console_sessions - models/console_session.go: rdp_user_enc, rdp_pass_enc
|
||||
//
|
||||
// settings is deliberately absent: it holds no ciphertext at all. The ESO read
|
||||
// token is stored as a SHA-256 hash, which no key opens.
|
||||
|
||||
+5
-2
@@ -40,9 +40,12 @@ const (
|
||||
// than 403, because the page has to render an explanation to a member of
|
||||
// the public who cannot do anything about it.
|
||||
FeatureStatusPages = "status_pages"
|
||||
// FeatureMCP gates agent access over the Model Context Protocol. It is
|
||||
// opt-in per customer like console and OIDC, so no plan bundles it.
|
||||
FeatureMCP = "mcp"
|
||||
)
|
||||
|
||||
// Support levels. Carried for display and enforced by nothing — there is no code
|
||||
// Support levels. Carried for display and enforced by nothing - there is no code
|
||||
// path anywhere that branches on these, and there must not be one. They are here
|
||||
// so an air-gapped install can tell its operator who to call without reaching
|
||||
// Vantage HQ.
|
||||
@@ -71,7 +74,7 @@ type Limits struct {
|
||||
// FillUnset replaces any zero field with the same field from base.
|
||||
//
|
||||
// This exists for one reason: a licence signed before a field existed decodes it
|
||||
// as 0, and 0 would read as the most restrictive possible value — no monitors,
|
||||
// as 0, and 0 would read as the most restrictive possible value - no monitors,
|
||||
// and an audit log trimmed to nothing. A blob we cannot re-sign must not be
|
||||
// allowed to mean that.
|
||||
//
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ package license
|
||||
//
|
||||
// This table is the seed. The admin service owns the authoritative copy in its
|
||||
// `plans` collection, and every issued licence snapshots the plan it was cut
|
||||
// from — so editing a plan never rewrites an existing licence, the same rule as
|
||||
// from - so editing a plan never rewrites an existing licence, the same rule as
|
||||
// workflow_runs.steps_snapshot.
|
||||
//
|
||||
// Limits here are the BASE allowance: what the tier grants before anything is
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ func Verify(blob string, opts VerifyOpts) Result {
|
||||
// Parse verifies the signature only, ignoring binding and expiry.
|
||||
//
|
||||
// Used to display a licence and to inspect a blob a customer has emailed in.
|
||||
// Never use it for enforcement — it does not check who the licence is for.
|
||||
// Never use it for enforcement - it does not check who the licence is for.
|
||||
func Parse(blob string) (License, error) {
|
||||
parsed, err := lk.LicenseFromB32String(blob)
|
||||
if err != nil {
|
||||
|
||||
+22
-4
@@ -14,24 +14,42 @@ const VerifyWindow = 24 * time.Hour
|
||||
// The link is built from the Sender's PublicURL, because the address of the
|
||||
// portal is a property of the deployment, not of the call site.
|
||||
func (s Sender) SendVerification(to, token string) error {
|
||||
return s.sendTemplate(to, "", "verification", struct {
|
||||
return s.sendTemplate(to, "", "verification", s.verificationData(to, token))
|
||||
}
|
||||
|
||||
// verificationData is split from its Send method, as every message's is, so
|
||||
// render_test.go renders the exact struct the message is sent with.
|
||||
func (s Sender) verificationData(to, token string) any {
|
||||
return struct {
|
||||
Email string
|
||||
Link string
|
||||
TTLHours int
|
||||
Expires time.Time
|
||||
}{
|
||||
Email: to,
|
||||
Link: fmt.Sprintf("%s/verify?token=%s", s.PublicURL, token),
|
||||
TTLHours: int(VerifyWindow.Hours()),
|
||||
})
|
||||
Expires: time.Now().UTC().Add(VerifyWindow),
|
||||
}
|
||||
}
|
||||
|
||||
// SendInvite asks someone to join an existing account and set their own
|
||||
// password. It names the account, because an unexpected invitation from a
|
||||
// service you have never used is otherwise indistinguishable from spam.
|
||||
func (s Sender) SendInvite(to, accountName, token string) error {
|
||||
return s.sendTemplate(to, "", "invite", struct {
|
||||
return s.sendTemplate(to, "", "invite", s.inviteData(to, accountName, token))
|
||||
}
|
||||
|
||||
func (s Sender) inviteData(to, accountName, token string) any {
|
||||
return struct {
|
||||
Email string
|
||||
AccountName string
|
||||
Link string
|
||||
TTLHours int
|
||||
}{
|
||||
Email: to,
|
||||
AccountName: accountName,
|
||||
Link: fmt.Sprintf("%s/accept-invite?token=%s", s.PublicURL, token),
|
||||
})
|
||||
TTLHours: int(VerifyWindow.Hours()),
|
||||
}
|
||||
}
|
||||
|
||||
+7
-5
@@ -3,16 +3,18 @@ package mail
|
||||
// SendCancelled confirms a cancellation and states what stays true: the licence
|
||||
// keeps working until it expires, then the instance degrades to read-only.
|
||||
func (s Sender) SendCancelled(to, instanceName string) error {
|
||||
return s.sendTemplate(to, "", "cancelled", struct {
|
||||
InstanceName string
|
||||
}{instanceName})
|
||||
return s.sendTemplate(to, "", "cancelled", s.billingData(instanceName))
|
||||
}
|
||||
|
||||
// SendPastDue notifies of a failed charge without alarming: the licence is
|
||||
// untouched while Paddle retries the card.
|
||||
func (s Sender) SendPastDue(to, instanceName string) error {
|
||||
return s.sendTemplate(to, "", "pastdue", struct {
|
||||
return s.sendTemplate(to, "", "pastdue", s.billingData(instanceName))
|
||||
}
|
||||
|
||||
func (s Sender) billingData(instanceName string) any {
|
||||
return struct {
|
||||
InstanceName string
|
||||
PortalURL string
|
||||
}{instanceName, s.PublicURL})
|
||||
}{instanceName, s.PublicURL}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package mail
|
||||
|
||||
import "time"
|
||||
|
||||
// DisputeNotice is everything the four account-dispute emails say.
|
||||
//
|
||||
// vantage-admin's dispute package builds it. It never carries the staff note:
|
||||
// the customer is told the reason category, not what staff wrote about them.
|
||||
type DisputeNotice struct {
|
||||
AccountName string
|
||||
DisputeID string
|
||||
Reason string
|
||||
CloudNames []string
|
||||
SelfHosted []SelfHostedNotice
|
||||
DisputeEmail string
|
||||
DisputeBy time.Time
|
||||
}
|
||||
|
||||
// SelfHostedNotice names a self-hosted install and when its licence ends,
|
||||
// because that is the one thing a dispute cannot change for it.
|
||||
type SelfHostedNotice struct {
|
||||
Name string
|
||||
LicenceExpires time.Time
|
||||
}
|
||||
|
||||
// InstanceRows lists the affected instances in the shape the layout's "rows"
|
||||
// helper takes. A method rather than template logic, because a Go template
|
||||
// has no way to build a list of maps.
|
||||
func (n DisputeNotice) InstanceRows() []map[string]any {
|
||||
rows := make([]map[string]any, 0, len(n.CloudNames)+len(n.SelfHosted))
|
||||
for _, name := range n.CloudNames {
|
||||
rows = append(rows, map[string]any{"k": "Cloud", "v": name})
|
||||
}
|
||||
for _, s := range n.SelfHosted {
|
||||
rows = append(rows, map[string]any{
|
||||
"k": "Self-hosted",
|
||||
"v": s.Name + ", licence ends " + s.LicenceExpires.Format("2 January 2006"),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// SendAccountLocked tells the account a dispute has locked it, that nothing is
|
||||
// deleted, and how to dispute. Replies go to the dispute address.
|
||||
func (s Sender) SendAccountLocked(to string, n DisputeNotice) error {
|
||||
return s.sendTemplate(to, n.DisputeEmail, "accountlocked", n)
|
||||
}
|
||||
|
||||
// SendDisputeReminder repeats the dispute route shortly before the hold ends.
|
||||
func (s Sender) SendDisputeReminder(to string, n DisputeNotice) error {
|
||||
return s.sendTemplate(to, n.DisputeEmail, "disputereminder", n)
|
||||
}
|
||||
|
||||
// SendAccountTerminated states the outcome of a failed dispute.
|
||||
func (s Sender) SendAccountTerminated(to string, n DisputeNotice) error {
|
||||
return s.sendTemplate(to, n.DisputeEmail, "accountterminated", n)
|
||||
}
|
||||
|
||||
// SendAccountRestored states the outcome of an upheld dispute.
|
||||
func (s Sender) SendAccountRestored(to string, n DisputeNotice) error {
|
||||
return s.sendTemplate(to, "", "accountrestored", n)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sampleNotice() DisputeNotice {
|
||||
return DisputeNotice{
|
||||
AccountName: "Smith & Sons",
|
||||
DisputeID: "dsp_123",
|
||||
Reason: "Breach of the terms of service",
|
||||
CloudNames: []string{"smith-prod"},
|
||||
SelfHosted: []SelfHostedNotice{{Name: "smith-dc", LicenceExpires: time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)}},
|
||||
DisputeEmail: "disputes@vantage.example",
|
||||
DisputeBy: time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
var disputeTemplates = []string{"accountlocked", "disputereminder", "accountterminated", "accountrestored"}
|
||||
|
||||
func TestDisputeTemplatesRender(t *testing.T) {
|
||||
for _, name := range disputeTemplates {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
m, err := render(name, sampleNotice())
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.Contains(m.Subject, "Smith & Sons") {
|
||||
t.Errorf("subject %q does not name the account verbatim", m.Subject)
|
||||
}
|
||||
for label, body := range map[string]string{"text": m.Text, "html": m.HTML} {
|
||||
if strings.Contains(body, "<no value>") {
|
||||
t.Errorf("%s body has an unfilled field", label)
|
||||
}
|
||||
if strings.Contains(body, "—") {
|
||||
t.Errorf("%s body contains an em dash", label)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisputeRouteIsStated(t *testing.T) {
|
||||
for _, name := range []string{"accountlocked", "disputereminder"} {
|
||||
m, err := render(name, sampleNotice())
|
||||
if err != nil {
|
||||
t.Fatalf("render %s: %v", name, err)
|
||||
}
|
||||
for _, want := range []string{"disputes@vantage.example", "dsp_123", "17 September 2026"} {
|
||||
if !strings.Contains(m.Text, want) {
|
||||
t.Errorf("%s text is missing %q", name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceRows(t *testing.T) {
|
||||
rows := sampleNotice().InstanceRows()
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("got %d rows, want 2", len(rows))
|
||||
}
|
||||
if rows[0]["v"] != "smith-prod" {
|
||||
t.Errorf("cloud row = %v", rows[0])
|
||||
}
|
||||
if v, _ := rows[1]["v"].(string); !strings.Contains(v, "licence ends 2 November 2026") {
|
||||
t.Errorf("self-hosted row = %v", rows[1])
|
||||
}
|
||||
}
|
||||
+39
-13
@@ -5,13 +5,18 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SendLicense delivers the blob inline. It is signed public data, not a secret —
|
||||
// SendLicense delivers the blob inline. It is signed public data, not a secret -
|
||||
// it is useless on any instance other than the one it names.
|
||||
func (s Sender) SendLicense(to, instanceName, blob string) error {
|
||||
return s.sendTemplate(to, "", "license", struct {
|
||||
return s.sendTemplate(to, "", "license", licenseData(instanceName, blob))
|
||||
}
|
||||
|
||||
func licenseData(instanceName, blob string) any {
|
||||
return struct {
|
||||
InstanceName string
|
||||
Blob string
|
||||
}{instanceName, blob})
|
||||
Issued time.Time
|
||||
}{instanceName, blob, time.Now().UTC()}
|
||||
}
|
||||
|
||||
// SendInstanceReady tells a customer their cloud instance exists, where it is,
|
||||
@@ -21,28 +26,40 @@ func (s Sender) SendLicense(to, instanceName, blob string) error {
|
||||
// that quietly expires in a month is a surprise, and the first email is the one
|
||||
// people keep.
|
||||
func (s Sender) SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error {
|
||||
return s.sendTemplate(to, "", "instanceready", struct {
|
||||
return s.sendTemplate(to, "", "instanceready", instanceReadyData(instanceName, loginURL, expires))
|
||||
}
|
||||
|
||||
func instanceReadyData(instanceName, loginURL string, expires time.Time) any {
|
||||
return struct {
|
||||
InstanceName string
|
||||
LoginURL string
|
||||
Expires time.Time
|
||||
}{instanceName, loginURL, expires})
|
||||
}{instanceName, loginURL, expires}
|
||||
}
|
||||
|
||||
// SendRenewed confirms a renewal and states the new date.
|
||||
func (s Sender) SendRenewed(to, instanceName string, expires time.Time) error {
|
||||
return s.sendTemplate(to, "", "renewed", struct {
|
||||
return s.sendTemplate(to, "", "renewed", renewedData(instanceName, expires))
|
||||
}
|
||||
|
||||
func renewedData(instanceName string, expires time.Time) any {
|
||||
return struct {
|
||||
InstanceName string
|
||||
Expires time.Time
|
||||
}{instanceName, expires})
|
||||
}{instanceName, expires}
|
||||
}
|
||||
|
||||
// SendExpiring is the renew-now nudge, seven days out.
|
||||
func (s Sender) SendExpiring(to, instanceName, portalURL string, expires time.Time) error {
|
||||
return s.sendTemplate(to, "", "expiring", struct {
|
||||
return s.sendTemplate(to, "", "expiring", expiringData(instanceName, portalURL, expires))
|
||||
}
|
||||
|
||||
func expiringData(instanceName, portalURL string, expires time.Time) any {
|
||||
return struct {
|
||||
InstanceName string
|
||||
PortalURL string
|
||||
Expires time.Time
|
||||
}{instanceName, portalURL, expires})
|
||||
}{instanceName, portalURL, expires}
|
||||
}
|
||||
|
||||
// SendExpired states plainly what has stopped and what happens next.
|
||||
@@ -51,23 +68,32 @@ func (s Sender) SendExpiring(to, instanceName, portalURL string, expires time.Ti
|
||||
// sequence is that nobody loses an instance without having been told a date. A
|
||||
// zero deleteOn means the reaper is disabled, and then no date is claimed.
|
||||
func (s Sender) SendExpired(to, instanceName, portalURL string, deleteOn time.Time) error {
|
||||
return s.sendTemplate(to, "", "expired", struct {
|
||||
return s.sendTemplate(to, "", "expired", expiredData(instanceName, portalURL, deleteOn))
|
||||
}
|
||||
|
||||
func expiredData(instanceName, portalURL string, deleteOn time.Time) any {
|
||||
return struct {
|
||||
InstanceName string
|
||||
PortalURL string
|
||||
DeleteOn time.Time
|
||||
}{instanceName, portalURL, deleteOn})
|
||||
}{instanceName, portalURL, deleteOn}
|
||||
}
|
||||
|
||||
// SendDeletionWarning is the final countdown, sent at seven days and one day.
|
||||
func (s Sender) SendDeletionWarning(to, instanceName, portalURL string, deleteOn time.Time, daysLeft int) error {
|
||||
return s.sendTemplate(to, "", "deletionwarning", deletionWarningData(instanceName, portalURL, deleteOn, daysLeft))
|
||||
}
|
||||
|
||||
func deletionWarningData(instanceName, portalURL string, deleteOn time.Time, daysLeft int) any {
|
||||
when := fmt.Sprintf("in %d days", daysLeft)
|
||||
if daysLeft <= 1 {
|
||||
when = "tomorrow"
|
||||
}
|
||||
return s.sendTemplate(to, "", "deletionwarning", struct {
|
||||
return struct {
|
||||
InstanceName string
|
||||
PortalURL string
|
||||
When string
|
||||
DaysLeft int
|
||||
DeleteOn time.Time
|
||||
}{instanceName, portalURL, when, deleteOn})
|
||||
}{instanceName, portalURL, when, daysLeft, deleteOn}
|
||||
}
|
||||
|
||||
+100
-6
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
htmltmpl "html/template"
|
||||
"io/fs"
|
||||
"math"
|
||||
"strings"
|
||||
texttmpl "text/template"
|
||||
"time"
|
||||
@@ -36,11 +37,13 @@ func init() {
|
||||
continue
|
||||
}
|
||||
h, err := htmltmpl.New("layout.html.tmpl").Funcs(htmltmpl.FuncMap(funcs)).
|
||||
Funcs(htmltmpl.FuncMap(perRender("", "accent"))).
|
||||
ParseFS(files, "templates/layout.html.tmpl", e)
|
||||
if err != nil {
|
||||
panic("mail: parse " + e + ": " + err.Error())
|
||||
}
|
||||
t, err := texttmpl.New("layout.txt.tmpl").Funcs(texttmpl.FuncMap(funcs)).
|
||||
Funcs(texttmpl.FuncMap(perRender("", "accent"))).
|
||||
ParseFS(files, "templates/layout.txt.tmpl", "templates/"+name+".txt.tmpl")
|
||||
if err != nil {
|
||||
panic("mail: parse " + name + ".txt.tmpl: " + err.Error())
|
||||
@@ -49,25 +52,58 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// render produces the subject and both bodies for one message.
|
||||
// perRender are the funcs whose value belongs to one message rather than to
|
||||
// the package: the sender's portal address for the footer links, and the
|
||||
// message's tone, which the layout needs before the body has run. They are
|
||||
// registered with placeholders at parse time and rebound on a clone per render.
|
||||
func perRender(hq, tone string) map[string]any {
|
||||
return map[string]any{
|
||||
"hq": func() string { return hq },
|
||||
"tone": func() string { return tone },
|
||||
}
|
||||
}
|
||||
|
||||
// render produces the subject and both bodies for one message. hq is the
|
||||
// sender's PublicURL; empty drops the footer links.
|
||||
//
|
||||
// The subject comes from the text set, not the HTML one: html/template would
|
||||
// escape an ampersand in an instance name into "&" and mail clients show
|
||||
// subjects verbatim.
|
||||
func render(name string, data any) (message, error) {
|
||||
func render(name string, data any, hq ...string) (message, error) {
|
||||
s, ok := sets[name]
|
||||
if !ok {
|
||||
return message{}, fmt.Errorf("no such template")
|
||||
}
|
||||
var hqURL string
|
||||
if len(hq) > 0 {
|
||||
hqURL = hq[0]
|
||||
}
|
||||
|
||||
var tone strings.Builder
|
||||
if err := s.text.ExecuteTemplate(&tone, "tone", data); err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
per := perRender(hqURL, strings.TrimSpace(tone.String()))
|
||||
|
||||
ht, err := s.html.Clone()
|
||||
if err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
ht.Funcs(htmltmpl.FuncMap(per))
|
||||
tt, err := s.text.Clone()
|
||||
if err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
tt.Funcs(texttmpl.FuncMap(per))
|
||||
|
||||
var subject, text, html strings.Builder
|
||||
if err := s.text.ExecuteTemplate(&subject, "subject", data); err != nil {
|
||||
if err := tt.ExecuteTemplate(&subject, "subject", data); err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
if err := s.text.Execute(&text, data); err != nil {
|
||||
if err := tt.Execute(&text, data); err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
if err := s.html.Execute(&html, data); err != nil {
|
||||
if err := ht.Execute(&html, data); err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
|
||||
@@ -90,7 +126,7 @@ func normaliseText(s string) string {
|
||||
}
|
||||
|
||||
// funcs are shared by both template flavours. They exist so that a message
|
||||
// template never formats a date or builds a structure itself — two templates
|
||||
// template never formats a date or builds a structure itself - two templates
|
||||
// formatting the same date two ways is exactly the drift this package removes.
|
||||
var funcs = map[string]any{
|
||||
// dict builds a map for the layout's helper templates, which take more
|
||||
@@ -110,6 +146,24 @@ var funcs = map[string]any{
|
||||
return m, nil
|
||||
},
|
||||
"list": func(v ...any) []any { return v },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
|
||||
// pick chooses one of five values by tone: up, down, pend, accent, and
|
||||
// anything else. It lets the layout keep every hex while the tone of a
|
||||
// message is decided by the message.
|
||||
"pick": func(tone any, up, down, pend, accent, other string) string {
|
||||
switch fmt.Sprint(tone) {
|
||||
case "up":
|
||||
return up
|
||||
case "down":
|
||||
return down
|
||||
case "pend":
|
||||
return pend
|
||||
case "accent":
|
||||
return accent
|
||||
}
|
||||
return other
|
||||
},
|
||||
|
||||
// date is the one long-date format used across every Vantage email.
|
||||
"date": func(t time.Time) string { return t.Format("2 January 2006") },
|
||||
@@ -118,4 +172,44 @@ var funcs = map[string]any{
|
||||
"stamp": func(t time.Time) string { return t.Format("2006-01-02 15:04:05 MST") },
|
||||
"hours": func(d time.Duration) int { return int(d.Hours()) },
|
||||
"upper": strings.ToUpper,
|
||||
"year": func() int { return time.Now().Year() },
|
||||
|
||||
// daysUntil counts whole days left, rounding up so "expires tomorrow
|
||||
// afternoon" reads as 1, never 0. A date in the past is 0.
|
||||
"daysUntil": func(t time.Time) int {
|
||||
d := time.Until(t)
|
||||
if d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(math.Ceil(d.Hours() / 24))
|
||||
},
|
||||
"plural": func(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return fmt.Sprintf("%d %s", n, one)
|
||||
}
|
||||
return fmt.Sprintf("%d %s", n, many)
|
||||
},
|
||||
|
||||
// sevTone maps a scanner severity onto a layout tone, so the vulnerability
|
||||
// digest colours a finding the same way the control plane does.
|
||||
"sevTone": func(sev string) string {
|
||||
switch strings.ToLower(sev) {
|
||||
case "critical", "high":
|
||||
return "down"
|
||||
case "medium":
|
||||
return "pend"
|
||||
}
|
||||
return "accent"
|
||||
},
|
||||
// advisoryURL links a finding to its public advisory when the ID says
|
||||
// where that is. Other IDs get no link rather than a guessed one.
|
||||
"advisoryURL": func(id string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(id, "CVE-"):
|
||||
return "https://nvd.nist.gov/vuln/detail/" + id
|
||||
case strings.HasPrefix(id, "GHSA-"):
|
||||
return "https://github.com/advisories/" + id
|
||||
}
|
||||
return ""
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cases renders every message with realistic data. Each entry's key is the
|
||||
// template name, optionally with a "/variant" suffix for a message whose
|
||||
// branches are worth seeing separately.
|
||||
func cases() map[string]any {
|
||||
s := Sender{PublicURL: "https://vantage-hq.hostxtra.co.uk"}
|
||||
now := time.Now().UTC()
|
||||
day := 24 * time.Hour
|
||||
return map[string]any{
|
||||
"verification": s.verificationData("sam@example.com", "3f9c2a7d1e"),
|
||||
"invite": s.inviteData("sam@example.com", "Northwind Ops", "8a1b2c3d4e"),
|
||||
"license": licenseData("northwind-prod",
|
||||
"eyJ2IjoxLCJpbnN0YW5jZSI6Im5vcnRod2luZC1wcm9kIiwidGllciI6ImZyZWUiLCJleHAiOjE3OTk5OTk5OTl9.MEUCIQDk3v1rX0sX7kQ2m9WQ1o0q8pWm6lq3vRjz8yN0yX4bGQIgYk2Fh7s"),
|
||||
"instanceready": instanceReadyData("northwind-prod", "https://northwind-prod.vantage.hostxtra.co.uk", now.Add(30*day)),
|
||||
"instanceready/nologin": instanceReadyData("northwind-prod", "", now.Add(30*day)),
|
||||
"renewed": renewedData("northwind-prod", now.Add(30*day)),
|
||||
"expiring": expiringData("northwind-prod", s.PublicURL, now.Add(7*day)),
|
||||
"expired": expiredData("northwind-prod", s.PublicURL, now.Add(14*day)),
|
||||
"expired/noreaper": expiredData("northwind-prod", s.PublicURL, time.Time{}),
|
||||
"deletionwarning": deletionWarningData("northwind-prod", s.PublicURL, now.Add(7*day), 7),
|
||||
"deletionwarning/1day": deletionWarningData("northwind-prod", s.PublicURL, now.Add(day), 1),
|
||||
"cancelled": s.billingData("northwind-prod"),
|
||||
"pastdue": s.billingData("northwind-prod"),
|
||||
"monitoralert": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "up", NewStatus: "down", Message: "GET /healthz returned 503 Service Unavailable after 2.4s", Time: now, Down: true},
|
||||
"monitoralert/recovered": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "down", NewStatus: "up", Message: "200 OK in 182ms", Time: now},
|
||||
"contact": struct {
|
||||
Enquiry
|
||||
Received string
|
||||
}{Enquiry{Name: "Priya Shah", Email: "priya@acme.io", Servers: "50-100", Topic: "Sales",
|
||||
Message: "Hi,\n\nWe run about 80 Linux boxes across two sites and are looking at Vantage for patching and uptime.\nCould we get a demo next week?"}, now.Format(time.RFC1123)},
|
||||
"vuln_digest": VulnDigest{InstanceName: "northwind-prod", Count: 14, TopSeverity: "critical",
|
||||
Summary: "14 new findings across 3 servers, 2 of them critical.",
|
||||
Rows: []VulnDigestRow{
|
||||
{CVEID: "CVE-2026-31337", Severity: "critical", PackageName: "openssl", ServerName: "web-01", FixedIn: "3.0.15-1"},
|
||||
{CVEID: "CVE-2026-29001", Severity: "critical", PackageName: "sudo", ServerName: "db-02", FixedIn: "1.9.16p2"},
|
||||
{CVEID: "GHSA-4xq2-9r7m-p3wv", Severity: "high", PackageName: "golang.org/x/net", ServerName: "web-01"},
|
||||
{CVEID: "CVE-2026-20488", Severity: "medium", PackageName: "curl", ServerName: "worker-03", FixedIn: "8.9.1"},
|
||||
{CVEID: "CVE-2025-48112", Severity: "low", PackageName: "less", ServerName: "worker-03"},
|
||||
},
|
||||
More: 9, DBAge: "2 days"},
|
||||
"accountlocked": sampleNotice(),
|
||||
"disputereminder": sampleNotice(),
|
||||
"accountterminated": sampleNotice(),
|
||||
"accountrestored": sampleNotice(),
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderAll renders every template and fails if one exists that no case
|
||||
// covers. The templates are parsed in init(), so without this test a mistyped
|
||||
// field is a boot-time panic in three services.
|
||||
//
|
||||
// Set MAIL_PREVIEW_DIR to also write each rendering to disk for a look.
|
||||
func TestRenderAll(t *testing.T) {
|
||||
covered := map[string]bool{}
|
||||
dir := os.Getenv("MAIL_PREVIEW_DIR")
|
||||
|
||||
for key, data := range cases() {
|
||||
name, _, _ := strings.Cut(key, "/")
|
||||
covered[name] = true
|
||||
|
||||
m, err := render(name, data, "https://vantage-hq.hostxtra.co.uk")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", key, err)
|
||||
}
|
||||
if m.Subject == "" {
|
||||
t.Errorf("%s: empty subject", key)
|
||||
}
|
||||
for part, body := range map[string]string{"subject": m.Subject, "text": m.Text, "html": m.HTML} {
|
||||
if strings.Contains(body, "<no value>") {
|
||||
t.Errorf("%s: %s part has <no value>", key, part)
|
||||
}
|
||||
if strings.Contains(body, "—") {
|
||||
t.Errorf("%s: %s part has an em dash", key, part)
|
||||
}
|
||||
}
|
||||
|
||||
if dir != "" {
|
||||
base := filepath.Join(dir, strings.ReplaceAll(key, "/", "--"))
|
||||
_ = os.WriteFile(base+".html", []byte(m.HTML), 0o644)
|
||||
_ = os.WriteFile(base+".txt", []byte(m.Text), 0o644)
|
||||
_ = os.WriteFile(base+".subject", []byte(m.Subject), 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
for name := range sets {
|
||||
if !covered[name] {
|
||||
t.Errorf("template %q has no render case", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderNoPortal checks the footer degrades when the sender has no
|
||||
// PublicURL, as a monitor notification channel does not.
|
||||
func TestRenderNoPortal(t *testing.T) {
|
||||
m, err := render("monitoralert", MonitorEvent{MonitorName: "m", Type: "tcp", Time: time.Now(), Down: true}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(m.HTML, "/billing") {
|
||||
t.Error("footer links rendered without a portal URL")
|
||||
}
|
||||
}
|
||||
+46
-21
@@ -3,7 +3,7 @@
|
||||
// It owns three things that used to exist in three copies: the SMTP
|
||||
// conversation (including the 465-implicit-TLS case that net/smtp gets wrong),
|
||||
// the RFC 5322 envelope, and the rendered look of a Vantage email. Callers see
|
||||
// only typed Send* methods — nobody outside this package builds a subject line,
|
||||
// only typed Send* methods - nobody outside this package builds a subject line,
|
||||
// a MIME part or a colour.
|
||||
package mail
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
netmail "net/mail"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"os"
|
||||
@@ -24,7 +26,7 @@ import (
|
||||
|
||||
// timeout bounds the whole SMTP conversation. Without it a mail server that
|
||||
// accepts the connection and then stalls holds an HTTP request open until the
|
||||
// client gives up — and admin's signup rollback runs on that request's context.
|
||||
// client gives up - and admin's signup rollback runs on that request's context.
|
||||
const timeout = 15 * time.Second
|
||||
|
||||
// Sender is a configured SMTP destination. It is a value, not a singleton:
|
||||
@@ -78,7 +80,7 @@ type message struct {
|
||||
|
||||
// sendTemplate renders name against data and delivers the result.
|
||||
func (s Sender) sendTemplate(to, replyTo, name string, data any) error {
|
||||
m, err := render(name, data)
|
||||
m, err := render(name, data, s.PublicURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mail: render %s: %w", name, err)
|
||||
}
|
||||
@@ -92,7 +94,7 @@ func (s Sender) sendTemplate(to, replyTo, name string, data any) error {
|
||||
// Port 465 is implicit TLS: the server expects a TLS handshake immediately, so
|
||||
// the connection is wrapped BEFORE any SMTP is spoken. Every other port gets
|
||||
// plaintext then STARTTLS if offered. net/smtp.SendMail only does the latter,
|
||||
// which is why it fails against a 465 mail server — that bug silently stopped
|
||||
// which is why it fails against a 465 mail server - that bug silently stopped
|
||||
// every admin email from being delivered once already.
|
||||
func (s Sender) send(m message) error {
|
||||
if !s.Enabled() {
|
||||
@@ -135,11 +137,11 @@ func (s Sender) send(m message) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(s.From); err != nil {
|
||||
if err := client.Mail(addrSpec(s.From)); err != nil {
|
||||
return fmt.Errorf("smtp: mail from: %w", err)
|
||||
}
|
||||
for _, rcpt := range rcpts {
|
||||
if err := client.Rcpt(rcpt); err != nil {
|
||||
if err := client.Rcpt(addrSpec(rcpt)); err != nil {
|
||||
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
|
||||
}
|
||||
}
|
||||
@@ -162,6 +164,20 @@ func (s Sender) send(m message) error {
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// addrSpec is the bare address for the SMTP envelope. SMTP_FROM is usually
|
||||
// "Vantage <support@example.com>", which belongs in the From header only:
|
||||
// sent as MAIL FROM it became "<Vantage <support@example.com>>". Postfix
|
||||
// salvaged the address, so delivery and Return-Path looked fine, but rspamd
|
||||
// saw no envelope sender and mailcow skipped DKIM signing, so Gmail filed
|
||||
// every Vantage email as spam. Anything that does not parse is passed through
|
||||
// for the server to judge.
|
||||
func addrSpec(v string) string {
|
||||
if a, err := netmail.ParseAddress(v); err == nil {
|
||||
return a.Address
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func recipients(to string) []string {
|
||||
parts := strings.Split(to, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
@@ -180,27 +196,19 @@ func recipients(to string) []string {
|
||||
// text part is sent beside every HTML one for the same reason, and because a
|
||||
// client that refuses HTML should not receive a blank message. Header values
|
||||
// are stripped of CR and LF so a crafted instance name cannot inject headers.
|
||||
//
|
||||
// Both parts are quoted-printable. They were raw UTF-8 with no
|
||||
// Content-Transfer-Encoding, which means 7bit, and every template carries
|
||||
// non-ASCII (the middot in the masthead at least), which rspamd scored as
|
||||
// R_BAD_CTE_7BIT.
|
||||
func (s Sender) envelope(m message) ([]byte, error) {
|
||||
var parts strings.Builder
|
||||
w := multipart.NewWriter(&parts)
|
||||
|
||||
textPart, err := w.CreatePart(textproto.MIMEHeader{
|
||||
"Content-Type": {"text/plain; charset=utf-8"},
|
||||
})
|
||||
if err != nil {
|
||||
if err := writeQPPart(w, "text/plain; charset=utf-8", m.Text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := textPart.Write([]byte(m.Text)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
htmlPart, err := w.CreatePart(textproto.MIMEHeader{
|
||||
"Content-Type": {"text/html; charset=utf-8"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := htmlPart.Write([]byte(m.HTML)); err != nil {
|
||||
if err := writeQPPart(w, "text/html; charset=utf-8", m.HTML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -224,6 +232,23 @@ func (s Sender) envelope(m message) ([]byte, error) {
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
|
||||
// writeQPPart adds one quoted-printable part, so the wire stays 7-bit and
|
||||
// lines stay under SMTP's 998-byte limit whatever the template produced.
|
||||
func writeQPPart(w *multipart.Writer, contentType, body string) error {
|
||||
part, err := w.CreatePart(textproto.MIMEHeader{
|
||||
"Content-Type": {contentType},
|
||||
"Content-Transfer-Encoding": {"quoted-printable"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qp := quotedprintable.NewWriter(part)
|
||||
if _, err := qp.Write([]byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
return qp.Close()
|
||||
}
|
||||
|
||||
func messageID(from string) string {
|
||||
domain := "vantage.local"
|
||||
if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The envelope carries the bare address. "Vantage <x@y>" as MAIL FROM left
|
||||
// rspamd with no envelope sender, and mailcow skipped DKIM signing.
|
||||
func TestAddrSpecStripsDisplayName(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Vantage <support@example.com>": "support@example.com",
|
||||
"support@example.com": "support@example.com",
|
||||
" a@example.com ": "a@example.com",
|
||||
"not an address": "not an address",
|
||||
} {
|
||||
if got := addrSpec(in); got != want {
|
||||
t.Errorf("addrSpec(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every part must declare quoted-printable and put only 7-bit bytes on the
|
||||
// wire. Raw UTF-8 under an implied 7bit encoding scored R_BAD_CTE_7BIT in
|
||||
// rspamd.
|
||||
func TestEnvelopePartsAreQuotedPrintable(t *testing.T) {
|
||||
s := Sender{From: "Vantage <support@example.com>"}
|
||||
m := message{
|
||||
To: "a@example.com",
|
||||
Subject: "Smith & Sons · restored",
|
||||
Text: "VANTAGE · Account\nBilling has resumed.",
|
||||
HTML: "<p>VANTAGE · Account</p><p>" + strings.Repeat("x", 2000) + "</p>",
|
||||
}
|
||||
raw, err := s.envelope(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, c := range raw {
|
||||
if c > 0x7e && c != '\r' && c != '\n' {
|
||||
t.Fatalf("byte %d is 0x%x: the wire must be 7-bit", i, c)
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\r\n") {
|
||||
if len(line) > 998 {
|
||||
t.Fatalf("line of %d bytes exceeds SMTP's 998", len(line))
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := multipart.NewReader(msg.Body, params["boundary"])
|
||||
want := []string{m.Text, m.HTML}
|
||||
for i := range want {
|
||||
p, err := r.NextRawPart()
|
||||
if err != nil {
|
||||
t.Fatalf("part %d: %v", i, err)
|
||||
}
|
||||
if cte := p.Header.Get("Content-Transfer-Encoding"); cte != "quoted-printable" {
|
||||
t.Fatalf("part %d Content-Transfer-Encoding = %q", i, cte)
|
||||
}
|
||||
// NextPart would decode for us; NextRawPart keeps the check honest by
|
||||
// decoding here, so a missing header cannot pass by accident.
|
||||
body, err := io.ReadAll(quotedprintable.NewReader(p))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Quoted-printable text mode writes line breaks as CRLF, which is the
|
||||
// canonical form on the wire.
|
||||
if strings.ReplaceAll(string(body), "\r\n", "\n") != want[i] {
|
||||
t.Fatalf("part %d decodes to %q", i, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Account locked" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Your account is locked{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "We have locked the Vantage account %s. Reason: %s." .AccountName .Reason)}}
|
||||
{{template "p" "Nothing has been deleted. While the account is locked, sign-in is paused, cloud instances are offline and billing is paused."}}
|
||||
{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}}
|
||||
{{template "p" (printf "If you believe this is a mistake, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}}
|
||||
{{template "p" "If the dispute is not upheld, the account will be terminated and its cloud instances deleted."}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,10 @@
|
||||
{{define "subject"}}Your Vantage account {{.AccountName}} is locked{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Account locked" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Your account is locked{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "We have locked the Vantage account %s. Reason: %s." .AccountName .Reason)}}
|
||||
{{template "p" "Nothing has been deleted. While the account is locked, sign-in is paused, cloud instances are offline and billing is paused."}}
|
||||
{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}}
|
||||
{{template "p" (printf "If you believe this is a mistake, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}}
|
||||
{{template "p" "If the dispute is not upheld, the account will be terminated and its cloud instances deleted."}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Restored" "tone" "up")}}{{end}}
|
||||
{{define "title"}}Your account is restored{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "The dispute for %s is resolved and your account is restored." .AccountName)}}
|
||||
{{template "p" "Sign-in works again and cloud instances are back online. Billing has resumed on the period you already paid for, so there is no charge today."}}
|
||||
{{template "p" "You will need to sign in again."}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "subject"}}Your Vantage account {{.AccountName}} is restored{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Restored" "tone" "up")}}{{end}}
|
||||
{{define "title"}}Your account is restored{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "The dispute for %s is resolved and your account is restored." .AccountName)}}
|
||||
{{template "p" "Sign-in works again and cloud instances are back online. Billing has resumed on the period you already paid for, so there is no charge today."}}
|
||||
{{template "p" "You will need to sign in again."}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Terminated" "tone" "down")}}{{end}}
|
||||
{{define "title"}}Your account is terminated{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "The dispute for %s was not upheld, and the account is terminated. Reason: %s." .AccountName .Reason)}}
|
||||
{{template "p" "Every subscription is cancelled and no refund is due. Sign-in is removed and cloud instances are being deleted. This cannot be undone."}}
|
||||
{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}}
|
||||
{{if .SelfHosted}}{{template "p" "Self-hosted installs keep running until the licence date shown above. No new licence will be issued."}}{{end}}
|
||||
{{template "p" (printf "Questions: %s, quoting dispute reference %s." .DisputeEmail .DisputeID)}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,10 @@
|
||||
{{define "subject"}}Your Vantage account {{.AccountName}} is terminated{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Terminated" "tone" "down")}}{{end}}
|
||||
{{define "title"}}Your account is terminated{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "The dispute for %s was not upheld, and the account is terminated. Reason: %s." .AccountName .Reason)}}
|
||||
{{template "p" "Every subscription is cancelled and no refund is due. Sign-in is removed and cloud instances are being deleted. This cannot be undone."}}
|
||||
{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}}
|
||||
{{if .SelfHosted}}{{template "p" "Self-hosted installs keep running until the licence date shown above. No new licence will be issued."}}{{end}}
|
||||
{{template "p" (printf "Questions: %s, quoting dispute reference %s." .DisputeEmail .DisputeID)}}
|
||||
{{end}}
|
||||
@@ -1,6 +1,19 @@
|
||||
{{define "category"}}Billing{{end}}
|
||||
{{define "preheader"}}No further charges. {{.InstanceName}} keeps working until its licence expires.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Cancelled" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Your subscription is cancelled{{end}}
|
||||
{{define "title"}}Your subscription for {{.InstanceName}} is cancelled{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your subscription for %s is cancelled." .InstanceName)}}
|
||||
{{template "lead" (printf "Your subscription for %s is cancelled. No further payments will be taken." .InstanceName)}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Cancelled" "date" "Today" "state" "done")
|
||||
(dict "label" "Still active" "date" "Until licence expiry" "state" "now" "tone" "up")
|
||||
(dict "label" "Read-only" "date" "After that" "state" "next"))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Subscription" "v" "Cancelled")
|
||||
(dict "k" "Further charges" "v" "None"))}}
|
||||
{{template "p" "Your instance keeps working until the current licence expires. After that, monitors keep running but changes are disabled."}}
|
||||
{{if .PortalURL}}{{template "callout" (dict "tone" "accent" "title" "Changed your mind?" "text" "You can start a new subscription from Vantage HQ at any time.")}}
|
||||
{{template "button" (dict "label" "Open Vantage HQ" "url" .PortalURL)}}{{end}}
|
||||
{{end}}
|
||||
{{define "why"}}the subscription for {{.InstanceName}} was cancelled.{{end}}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
{{define "subject"}}Your Vantage subscription is cancelled{{end}}
|
||||
{{define "subject"}}Your Vantage subscription for {{.InstanceName}} is cancelled{{end}}
|
||||
{{define "tone"}}pend{{end}}
|
||||
{{define "category"}}Billing{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Cancelled" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Your subscription is cancelled{{end}}
|
||||
{{define "title"}}Your subscription for {{.InstanceName}} is cancelled{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your subscription for %s is cancelled." .InstanceName)}}
|
||||
{{template "lead" (printf "Your subscription for %s is cancelled. No further payments will be taken." .InstanceName)}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Cancelled" "date" "Today" "state" "done")
|
||||
(dict "label" "Still active" "date" "Until licence expiry" "state" "now" "tone" "up")
|
||||
(dict "label" "Read-only" "date" "After that" "state" "next"))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Subscription" "v" "Cancelled")
|
||||
(dict "k" "Further charges" "v" "None"))}}
|
||||
{{template "p" "Your instance keeps working until the current licence expires. After that, monitors keep running but changes are disabled."}}
|
||||
{{if .PortalURL}}{{template "callout" (dict "tone" "accent" "title" "Changed your mind?" "text" "You can start a new subscription from Vantage HQ at any time.")}}
|
||||
{{template "button" (dict "label" "Open Vantage HQ" "url" .PortalURL)}}{{end}}
|
||||
{{end}}
|
||||
{{define "why"}}the subscription for {{.InstanceName}} was cancelled.{{end}}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{{define "title"}}New contact enquiry{{end}}
|
||||
{{define "category"}}Enquiry{{end}}
|
||||
{{define "preheader"}}{{.Topic}} from {{.Name}}: {{.Message}}{{end}}
|
||||
{{define "pill"}}{{if .Topic}}{{template "chip" (dict "label" .Topic "tone" "accent")}}{{end}}{{end}}
|
||||
{{define "title"}}New enquiry from {{.Name}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" "Someone has used the contact form on the Vantage site."}}
|
||||
{{template "rows" (list
|
||||
@@ -7,5 +10,9 @@
|
||||
(dict "k" "Servers" "v" .Servers)
|
||||
(dict "k" "Topic" "v" .Topic)
|
||||
(dict "k" "Received" "v" .Received))}}
|
||||
{{template "label" "Message"}}
|
||||
{{template "note" .Message}}
|
||||
{{template "button" (dict "label" (printf "Reply to %s" .Name) "url" (printf "mailto:%s" .Email) "nofallback" true)}}
|
||||
{{template "small" (printf "Replying to this email also goes straight to %s." .Email)}}
|
||||
{{end}}
|
||||
{{define "why"}}this inbox receives the contact form on the Vantage site.{{end}}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{{define "subject"}}[Vantage] {{.Topic}} {{.Email}}{{end}}
|
||||
{{define "title"}}New contact enquiry{{end}}
|
||||
{{define "tone"}}accent{{end}}
|
||||
{{define "category"}}Enquiry{{end}}
|
||||
{{define "pill"}}{{if .Topic}}{{template "chip" (dict "label" .Topic "tone" "accent")}}{{end}}{{end}}
|
||||
{{define "title"}}New enquiry from {{.Name}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" "Someone has used the contact form on the Vantage site."}}
|
||||
{{template "rows" (list
|
||||
@@ -8,6 +11,8 @@
|
||||
(dict "k" "Servers" "v" .Servers)
|
||||
(dict "k" "Topic" "v" .Topic)
|
||||
(dict "k" "Received" "v" .Received))}}
|
||||
Message:
|
||||
{{template "label" "Message"}}
|
||||
{{template "note" .Message}}
|
||||
{{template "small" (printf "Reply to this email to answer %s directly." .Email)}}
|
||||
{{end}}
|
||||
{{define "why"}}this inbox receives the contact form on the Vantage site.{{end}}
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "preheader"}}Renew before {{date .DeleteOn}} to keep it. Deletion cannot be undone.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Deletion scheduled" "tone" "down")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} will be deleted {{.When}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s and everything in it will be deleted %s, on %s." .InstanceName .When (date .DeleteOn))}}
|
||||
{{template "p" "This cannot be undone. Renew it to keep it:"}}
|
||||
{{template "stats" (list
|
||||
(dict "big" (plural .DaysLeft "day" "days") "label" "until deletion" "tone" "down")
|
||||
(dict "big" (shortDate .DeleteOn) "label" "deletion date" "tone" "none"))}}
|
||||
{{template "button" (dict "label" "Keep this instance" "url" .PortalURL)}}
|
||||
{{template "callout" (dict "tone" "down" "title" "This cannot be undone" "text" (printf "Servers, agents, monitors, workflows, history and settings in %s are removed permanently. They cannot be restored afterwards." .InstanceName))}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Licence" "date" "Expired" "state" "done")
|
||||
(dict "label" "Read-only" "date" "Now" "state" "now" "tone" "pend")
|
||||
(dict "label" "Deleted" "date" (date .DeleteOn) "state" "next"))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Current state" "v" "Read-only, licence expired")
|
||||
(dict "k" "Deleted on" "v" (date .DeleteOn)))}}
|
||||
{{template "small" "Renewed in the last few minutes? No action is needed. Renewing moves the deletion date with the licence."}}
|
||||
{{end}}
|
||||
{{define "why"}}{{.InstanceName}} is scheduled for deletion and this is its billing contact.{{end}}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
{{define "subject"}}{{.InstanceName}} will be deleted {{.When}}{{end}}
|
||||
{{define "tone"}}down{{end}}
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Deletion scheduled" "tone" "down")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} will be deleted {{.When}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s and everything in it will be deleted %s, on %s." .InstanceName .When (date .DeleteOn))}}
|
||||
{{template "p" "This cannot be undone. Renew it to keep it:"}}
|
||||
{{template "button" (dict "label" "Keep this instance" "url" .PortalURL)}}
|
||||
{{template "callout" (dict "tone" "down" "title" "This cannot be undone" "text" (printf "Servers, agents, monitors, workflows, history and settings in %s are removed permanently. They cannot be restored afterwards." .InstanceName))}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Licence" "date" "Expired" "state" "done")
|
||||
(dict "label" "Read-only" "date" "Now" "state" "now" "tone" "pend")
|
||||
(dict "label" "Deleted" "date" (date .DeleteOn) "state" "next"))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Current state" "v" "Read-only, licence expired")
|
||||
(dict "k" "Deleted on" "v" (date .DeleteOn))
|
||||
(dict "k" "Time left" "v" (plural .DaysLeft "day" "days")))}}
|
||||
{{template "small" "Renewed in the last few minutes? No action is needed. Renewing moves the deletion date with the licence."}}
|
||||
{{end}}
|
||||
{{define "why"}}{{.InstanceName}} is scheduled for deletion and this is its billing contact.{{end}}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Dispute deadline" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Your account is still locked{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "The Vantage account %s is still locked. Reason: %s." .AccountName .Reason)}}
|
||||
{{template "p" (printf "To dispute this, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}}
|
||||
{{template "p" "After that date the dispute is decided. If it is not upheld, the account is terminated and its cloud instances deleted."}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "subject"}}{{.AccountName}}: dispute deadline {{shortDate .DisputeBy}}{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Dispute deadline" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Your account is still locked{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "The Vantage account %s is still locked. Reason: %s." .AccountName .Reason)}}
|
||||
{{template "p" (printf "To dispute this, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}}
|
||||
{{template "p" "After that date the dispute is decided. If it is not upheld, the account is terminated and its cloud instances deleted."}}
|
||||
{{end}}
|
||||
@@ -1,8 +1,23 @@
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "preheader"}}The licence has expired. Renew to unlock changes{{if not .DeleteOn.IsZero}} before {{date .DeleteOn}}{{end}}.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Read-only" "tone" "down")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} is now read-only{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s's Free licence has expired." .InstanceName)}}
|
||||
{{template "p" "Your servers and monitors keep running and your agents keep their keys, but changes are disabled."}}
|
||||
{{template "lead" (printf "%s's Free licence has expired, so the instance is now read-only." .InstanceName)}}
|
||||
{{if not .DeleteOn.IsZero}}{{template "stats" (list
|
||||
(dict "big" (plural (daysUntil .DeleteOn) "day" "days") "label" "until deletion" "tone" "down")
|
||||
(dict "big" (shortDate .DeleteOn) "label" "scheduled deletion" "tone" "none"))}}{{end}}
|
||||
{{template "button" (dict "label" "Renew now" "url" .PortalURL)}}
|
||||
{{if not .DeleteOn.IsZero}}{{template "p" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn))}}{{end}}
|
||||
{{template "label" "What still works"}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Servers and agents" "v" "Running, keys kept")
|
||||
(dict "k" "Monitors" "v" "Still checking")
|
||||
(dict "k" "Changes" "v" "Disabled until renewed")
|
||||
(dict "k" "Deletion" "v" (or (and (not .DeleteOn.IsZero) (date .DeleteOn)) "Not scheduled")))}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Active" "date" "Ended" "state" "done")
|
||||
(dict "label" "Read-only" "date" "Now" "state" "now" "tone" "down")
|
||||
(dict "label" "Deleted" "date" (or (and (not .DeleteOn.IsZero) (date .DeleteOn)) "Not scheduled") "state" "next"))}}
|
||||
{{if not .DeleteOn.IsZero}}{{template "callout" (dict "tone" "down" "title" "Deletion is scheduled" "text" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn)))}}{{end}}
|
||||
{{end}}
|
||||
{{define "why"}}the licence for {{.InstanceName}} has expired.{{end}}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
{{define "subject"}}{{.InstanceName}} is now read-only{{end}}
|
||||
{{define "tone"}}down{{end}}
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Read-only" "tone" "down")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} is now read-only{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s's Free licence has expired." .InstanceName)}}
|
||||
{{template "p" "Your servers and monitors keep running and your agents keep their keys, but changes are disabled."}}
|
||||
{{template "lead" (printf "%s's Free licence has expired, so the instance is now read-only." .InstanceName)}}
|
||||
{{template "button" (dict "label" "Renew now" "url" .PortalURL)}}
|
||||
{{if not .DeleteOn.IsZero}}{{template "p" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn))}}{{end}}
|
||||
{{template "label" "What still works"}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Servers and agents" "v" "Running, keys kept")
|
||||
(dict "k" "Monitors" "v" "Still checking")
|
||||
(dict "k" "Changes" "v" "Disabled until renewed")
|
||||
(dict "k" "Deletion" "v" (or (and (not .DeleteOn.IsZero) (date .DeleteOn)) "Not scheduled")))}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Active" "date" "Ended" "state" "done")
|
||||
(dict "label" "Read-only" "date" "Now" "state" "now" "tone" "down")
|
||||
(dict "label" "Deleted" "date" (or (and (not .DeleteOn.IsZero) (date .DeleteOn)) "Not scheduled") "state" "next"))}}
|
||||
{{if not .DeleteOn.IsZero}}{{template "callout" (dict "tone" "down" "title" "Deletion is scheduled" "text" (printf "If it is not renewed, the instance and everything in it will be deleted on %s, in %s." (date .DeleteOn) (plural (daysUntil .DeleteOn) "day" "days")))}}{{end}}
|
||||
{{end}}
|
||||
{{define "why"}}the licence for {{.InstanceName}} has expired.{{end}}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "preheader"}}Renew in one click to keep {{.InstanceName}} fully working after {{date .Expires}}.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Expiring soon" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s's Free licence runs out on %s." .InstanceName (date .Expires))}}
|
||||
{{template "lead" (printf "%s's Free licence runs out on %s. Renewing takes one click and costs nothing." .InstanceName (date .Expires))}}
|
||||
{{template "stats" (list
|
||||
(dict "big" (plural (daysUntil .Expires) "day" "days") "label" "left on the licence" "tone" "pend")
|
||||
(dict "big" (shortDate .Expires) "label" "expiry date" "tone" "none"))}}
|
||||
{{template "button" (dict "label" "Renew in one click" "url" .PortalURL)}}
|
||||
{{template "p" "If you do nothing, the instance keeps running but stops accepting changes."}}
|
||||
{{template "label" "Where you are"}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Active" "date" (printf "Until %s" (shortDate .Expires)) "state" "now" "tone" "pend")
|
||||
(dict "label" "Read-only" "date" "After expiry" "state" "next")
|
||||
(dict "label" "Deleted" "date" "If never renewed" "state" "next"))}}
|
||||
{{template "callout" (dict "tone" "pend" "title" "If you do nothing" "text" "The instance keeps running and monitoring, but stops accepting changes until it is renewed.")}}
|
||||
{{end}}
|
||||
{{define "why"}}the licence for {{.InstanceName}} expires within a week.{{end}}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
{{define "subject"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}}
|
||||
{{define "tone"}}pend{{end}}
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Expiring soon" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s's Free licence runs out on %s." .InstanceName (date .Expires))}}
|
||||
{{template "lead" (printf "%s's Free licence runs out on %s. Renewing takes one click and costs nothing." .InstanceName (date .Expires))}}
|
||||
{{template "stats" (list
|
||||
(dict "big" (plural (daysUntil .Expires) "day" "days") "label" "Left on the licence" "tone" "pend")
|
||||
(dict "big" (date .Expires) "label" "Expiry date" "tone" "none"))}}
|
||||
{{template "button" (dict "label" "Renew in one click" "url" .PortalURL)}}
|
||||
{{template "p" "If you do nothing, the instance keeps running but stops accepting changes."}}
|
||||
{{template "label" "Where you are"}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Active" "date" (printf "Until %s" (shortDate .Expires)) "state" "now" "tone" "pend")
|
||||
(dict "label" "Read-only" "date" "After expiry" "state" "next")
|
||||
(dict "label" "Deleted" "date" "If never renewed" "state" "next"))}}
|
||||
{{template "callout" (dict "tone" "pend" "title" "If you do nothing" "text" "The instance keeps running and monitoring, but stops accepting changes until it is renewed.")}}
|
||||
{{end}}
|
||||
{{define "why"}}the licence for {{.InstanceName}} expires within a week.{{end}}
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
{{define "category"}}Instance{{end}}
|
||||
{{define "preheader"}}{{.InstanceName}} is live. Your Free licence runs until {{date .Expires}}.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Ready" "tone" "up")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} is ready{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s is provisioned and waiting for you." .InstanceName)}}
|
||||
{{if .LoginURL}}{{template "button" (dict "label" "Sign in" "url" .LoginURL)}}{{end}}
|
||||
{{template "p" (printf "Your Free licence runs until %s. We will email you before then so you can renew it in one click." (date .Expires))}}
|
||||
{{template "p" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too."}}
|
||||
{{if .LoginURL}}{{template "button" (dict "label" (printf "Sign in to %s" .InstanceName) "url" .LoginURL)}}{{end}}
|
||||
{{template "stats" (list
|
||||
(dict "big" "Free" "label" "plan" "tone" "accent")
|
||||
(dict "big" (plural (daysUntil .Expires) "day" "days") "label" (printf "until renewal, on %s" (shortDate .Expires)) "tone" "up"))}}
|
||||
{{if .LoginURL}}{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Address" "v" .LoginURL)
|
||||
(dict "k" "Licence expires" "v" (date .Expires)))}}{{else}}{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Licence expires" "v" (date .Expires)))}}{{end}}
|
||||
{{template "label" "Get started"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Sign in" "d" "Use your Vantage account email and password.")
|
||||
(dict "t" "Connect your first server" "d" "Install the Vantage agent on a server and it joins your fleet.")
|
||||
(dict "t" "Add monitors and alerts" "d" "Watch the services that matter and choose who hears when they fail."))}}
|
||||
{{template "callout" (dict "tone" "accent" "title" "One password for everything" "text" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too.")}}
|
||||
{{template "small" "We will email you a week before the licence runs out, so you can renew it in one click."}}
|
||||
{{end}}
|
||||
{{define "why"}}you created {{.InstanceName}} in Vantage HQ.{{end}}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
{{define "subject"}}{{.InstanceName}} is ready{{end}}
|
||||
{{define "tone"}}up{{end}}
|
||||
{{define "category"}}Instance{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Ready" "tone" "up")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} is ready{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "%s is provisioned and waiting for you." .InstanceName)}}
|
||||
{{if .LoginURL}}{{template "button" (dict "label" "Sign in" "url" .LoginURL)}}{{end}}
|
||||
{{template "p" (printf "Your Free licence runs until %s. We will email you before then so you can renew it in one click." (date .Expires))}}
|
||||
{{template "p" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too."}}
|
||||
{{if .LoginURL}}{{template "button" (dict "label" (printf "Sign in to %s" .InstanceName) "url" .LoginURL)}}{{end}}
|
||||
{{template "stats" (list
|
||||
(dict "big" "Free" "label" "Plan" "tone" "accent")
|
||||
(dict "big" (plural (daysUntil .Expires) "day" "days") "label" (printf "Until renewal, on %s" (shortDate .Expires)) "tone" "up"))}}
|
||||
{{template "label" "Get started"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Sign in" "d" "Use your Vantage account email and password.")
|
||||
(dict "t" "Connect your first server" "d" "Install the Vantage agent on a server and it joins your fleet.")
|
||||
(dict "t" "Add monitors and alerts" "d" "Watch the services that matter and choose who hears when they fail."))}}
|
||||
{{template "callout" (dict "tone" "accent" "title" "One password for everything" "text" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too.")}}
|
||||
{{template "small" "We will email you a week before the licence runs out, so you can renew it in one click."}}
|
||||
{{end}}
|
||||
{{define "why"}}you created {{.InstanceName}} in Vantage HQ.{{end}}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
{{define "category"}}Account{{end}}
|
||||
{{define "preheader"}}Join {{.AccountName}} on Vantage. Set a password to accept.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Invitation" "tone" "accent")}}{{end}}
|
||||
{{define "title"}}You have been invited to {{.AccountName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "You have been invited to join %s on Vantage." .AccountName)}}
|
||||
{{template "p" "Set your own password and finish joining:"}}
|
||||
{{template "button" (dict "label" "Set password and join" "url" .Link)}}
|
||||
{{template "p" "This link expires in 24 hours. If you were not expecting this, ignore it — nothing happens until you open the link."}}
|
||||
{{template "lead" (printf "You have been invited to join %s on Vantage, where the team manages its servers, agents and monitors." .AccountName)}}
|
||||
{{template "button" (dict "label" "Accept invitation" "url" .Link)}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Account" "v" .AccountName)
|
||||
(dict "k" "Invited address" "v" .Email)
|
||||
(dict "k" "Link valid for" "v" (plural .TTLHours "hour" "hours")))}}
|
||||
{{template "label" "Joining takes a minute"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Accept the invitation" "d" "Open the link above from this inbox.")
|
||||
(dict "t" "Choose your password" "d" "It is yours alone. Nobody else on the account can see it.")
|
||||
(dict "t" "Sign in to Vantage HQ" "d" (printf "Use %s from then on." .Email)))}}
|
||||
{{template "callout" (dict "tone" "none" "title" "Not expecting this?" "text" "Ignore it. Nothing happens until you open the link, and it expires on its own.")}}
|
||||
{{end}}
|
||||
{{define "why"}}someone on the {{.AccountName}} account invited {{.Email}}.{{end}}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
{{define "subject"}}You have been invited to {{.AccountName}} on Vantage{{end}}
|
||||
{{define "tone"}}accent{{end}}
|
||||
{{define "category"}}Account{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Invitation" "tone" "accent")}}{{end}}
|
||||
{{define "title"}}You have been invited to {{.AccountName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "You have been invited to join %s on Vantage." .AccountName)}}
|
||||
{{template "p" "Set your own password and finish joining:"}}
|
||||
{{template "button" (dict "label" "Set password and join" "url" .Link)}}
|
||||
{{template "p" "This link expires in 24 hours. If you were not expecting this, ignore it — nothing happens until you open the link."}}
|
||||
{{template "lead" (printf "You have been invited to join %s on Vantage, where the team manages its servers, agents and monitors." .AccountName)}}
|
||||
{{template "button" (dict "label" "Accept invitation" "url" .Link)}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Account" "v" .AccountName)
|
||||
(dict "k" "Invited address" "v" .Email)
|
||||
(dict "k" "Link valid for" "v" (plural .TTLHours "hour" "hours")))}}
|
||||
{{template "label" "Joining takes a minute"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Accept the invitation" "d" "Open the link above from this inbox.")
|
||||
(dict "t" "Choose your password" "d" "It is yours alone. Nobody else on the account can see it.")
|
||||
(dict "t" "Sign in to Vantage HQ" "d" (printf "Use %s from then on." .Email)))}}
|
||||
{{template "callout" (dict "tone" "none" "title" "Not expecting this?" "text" "Ignore it. Nothing happens until you open the link, and it expires on its own.")}}
|
||||
{{end}}
|
||||
{{define "why"}}someone on the {{.AccountName}} account invited {{.Email}}.{{end}}
|
||||
|
||||
+209
-39
@@ -3,7 +3,7 @@
|
||||
|
||||
Every colour in the email system lives in this file and nowhere else, in the
|
||||
same way no component in web/, site/ or adminsite/ carries a hex. The values
|
||||
are web/app/globals.css's tokens — an email is read before the recipient
|
||||
are web/app/globals.css's tokens - an email is read before the recipient
|
||||
clicks through to the control plane, so the two should not look like
|
||||
different products. They are written as literal hex here because email
|
||||
clients support neither var() nor a reliable prefers-color-scheme, so the
|
||||
@@ -17,102 +17,272 @@
|
||||
--ink-2 #9fb3ca --pend #d6a63f
|
||||
--ink-3 #71879f --well #04101f
|
||||
|
||||
Layout is tables and inline styles throughout, which is not a stylistic
|
||||
choice — it is the only thing Outlook renders predictably.
|
||||
Derived here only, never in a front end: each tone has a tint (15% over
|
||||
--panel) for washes and a border (35% over --panel) for outlines.
|
||||
|
||||
A message file overrides "title", "pill" and "body"; the empty defaults below
|
||||
up #173743 / #245452 pend #2b3539 / #534f3a
|
||||
down #2d2d3d / #573d44 accent #193352 / #284b75
|
||||
|
||||
Layout is tables and inline styles throughout, which is not a stylistic
|
||||
choice - it is the only thing Outlook renders predictably.
|
||||
|
||||
A message file overrides "title", "pill", "body", "category", "preheader"
|
||||
and "why"; its txt file defines "subject" and "tone". The defaults below
|
||||
exist so that a message needing no pill does not have to define one.
|
||||
|
||||
Colour choices that depend on a tone go through pick, which takes the tone
|
||||
and one value per tone (up, down, pend, accent, anything else), so the hex
|
||||
stays in this file while the branching stays out of it.
|
||||
*/ -}}
|
||||
{{- define "title"}}{{end -}}
|
||||
{{- define "pill"}}{{end -}}
|
||||
{{- define "body"}}{{end -}}
|
||||
{{- define "category"}}Account{{end -}}
|
||||
{{- define "preheader"}}{{end -}}
|
||||
{{- define "why"}}it relates to your Vantage account.{{end -}}
|
||||
|
||||
{{- /* p renders one paragraph of body copy. */ -}}
|
||||
{{- define "p" -}}
|
||||
<p style="margin:0 0 16px;color:#9fb3ca;font-size:14px;line-height:1.6;">{{.}}</p>
|
||||
<p style="margin:0 0 16px;color:#9fb3ca;font-size:14px;line-height:1.65;">{{.}}</p>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* lead is the first paragraph: same size, brighter, sets the subject. */ -}}
|
||||
{{- /* lead is the first paragraph: larger, brighter, sets the subject. */ -}}
|
||||
{{- define "lead" -}}
|
||||
<p style="margin:0 0 16px;color:#e4ecf6;font-size:15px;line-height:1.6;">{{.}}</p>
|
||||
<p style="margin:0 0 20px;color:#e4ecf6;font-size:16px;line-height:1.6;">{{.}}</p>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* button takes dict "label" "…" "url" "…".
|
||||
{{- /* small is fine print under a section. */ -}}
|
||||
{{- define "small" -}}
|
||||
<p style="margin:0 0 16px;color:#71879f;font-size:12px;line-height:1.6;">{{.}}</p>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* label is an eyebrow over the section that follows it. */ -}}
|
||||
{{- define "label" -}}
|
||||
<p style="margin:24px 0 10px;color:#71879f;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;">{{.}}</p>
|
||||
{{- end -}}
|
||||
|
||||
{{- define "divider" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0 20px;"><tr><td style="height:1px;line-height:1px;font-size:0;background:#1e3855;"> </td></tr></table>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* button takes dict "label" "…" "url" "…", and optionally "nofallback".
|
||||
|
||||
The bare URL is printed underneath on purpose: a plain-text-preferring
|
||||
client, a stripped-styles inbox and a forwarded message all lose the
|
||||
anchor, and a verification email whose link cannot be reached is a
|
||||
support ticket. */ -}}
|
||||
support ticket. nofallback is for mailto: links, where it would be noise. */ -}}
|
||||
{{- define "button" -}}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:4px 0 16px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:4px 0 14px;">
|
||||
<tr>
|
||||
<td style="border-radius:4px;background:#5b9be8;">
|
||||
<a href="{{.url}}" style="display:inline-block;padding:10px 20px;color:#04101f;font-size:14px;font-weight:600;text-decoration:none;">{{.label}}</a>
|
||||
<td style="border-radius:6px;background:#5b9be8;">
|
||||
<a href="{{.url}}" style="display:inline-block;padding:12px 24px;color:#04101f;font-size:14px;font-weight:700;text-decoration:none;border-radius:6px;">{{.label}} →</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 20px;color:#71879f;font-size:12px;line-height:1.5;word-break:break-all;">
|
||||
Or paste this into your browser:<br>
|
||||
{{- if not .nofallback}}
|
||||
<p style="margin:0 0 22px;color:#71879f;font-size:12px;line-height:1.5;word-break:break-all;">
|
||||
Button not working? Paste this into your browser:<br>
|
||||
<a href="{{.url}}" style="color:#5b9be8;text-decoration:none;">{{.url}}</a>
|
||||
</p>
|
||||
{{- end}}
|
||||
{{- end -}}
|
||||
|
||||
{{- /* well shows machine output — a licence blob, an install ID. Mirrors
|
||||
{{- /* well shows machine output - a licence blob, an install ID. Mirrors
|
||||
web/'s --well surface, the floor beneath the ground. */ -}}
|
||||
{{- define "well" -}}
|
||||
<pre style="margin:0 0 20px;padding:14px;background:#04101f;border:1px solid #1e3855;border-radius:4px;color:#9fb3ca;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all;">{{.}}</pre>
|
||||
<pre style="margin:0 0 20px;padding:14px 16px;background:#04101f;border:1px solid #1e3855;border-radius:6px;color:#9fb3ca;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:12px;line-height:1.55;white-space:pre-wrap;word-break:break-all;">{{.}}</pre>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* note is a quoted callout, used for a free-text message we did not
|
||||
write ourselves. */ -}}
|
||||
write ourselves. Line breaks are kept: it is somebody's own words. */ -}}
|
||||
{{- define "note" -}}
|
||||
<p style="margin:0 0 20px;padding:12px 14px;background:#102842;border:1px solid #1e3855;border-radius:4px;color:#e4ecf6;font-size:13px;line-height:1.6;">{{.}}</p>
|
||||
<p style="margin:0 0 20px;padding:14px 16px;background:#102842;border:1px solid #1e3855;border-radius:6px;color:#e4ecf6;font-size:13px;line-height:1.65;white-space:pre-wrap;">{{.}}</p>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* rows takes a list of dict "k" "…" "v" "…". */ -}}
|
||||
{{- /* rows takes a list of dict "k" "…" "v" "…", and renders them as one
|
||||
panel of facts. */ -}}
|
||||
{{- define "rows" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 8px;border-top:1px solid #1e3855;">
|
||||
{{- range .}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 20px;background:#102842;border:1px solid #1e3855;border-radius:6px;border-collapse:separate;">
|
||||
{{- range $i, $r := .}}
|
||||
<tr>
|
||||
<td style="padding:9px 0;border-bottom:1px solid #172c44;color:#71879f;font-size:13px;width:130px;vertical-align:top;">{{.k}}</td>
|
||||
<td style="padding:9px 0;border-bottom:1px solid #172c44;color:#e4ecf6;font-size:13px;font-weight:500;">{{.v}}</td>
|
||||
<td style="padding:11px 16px;{{if $i}}border-top:1px solid #172c44;{{end}}color:#71879f;font-size:13px;width:140px;vertical-align:top;">{{$r.k}}</td>
|
||||
<td style="padding:11px 16px 11px 0;{{if $i}}border-top:1px solid #172c44;{{end}}color:#e4ecf6;font-size:13px;font-weight:600;word-break:break-word;">{{$r.v}}</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</table>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* chip takes dict "label" "…" "tone" "up|down|pend|accent". Tone is
|
||||
{{- /* chip takes dict "label" "…" "tone" "up|down|pend|accent|none". Tone is
|
||||
never the only signal: the label spells the state out. */ -}}
|
||||
{{- define "chip" -}}
|
||||
{{- $fg := "#5b9be8"}}{{if eq .tone "up"}}{{$fg = "#4fb484"}}{{else if eq .tone "down"}}{{$fg = "#e2705a"}}{{else if eq .tone "pend"}}{{$fg = "#d6a63f"}}{{end -}}
|
||||
<span style="display:inline-block;margin:0 0 12px;padding:4px 11px;border:1px solid {{$fg}};border-radius:9999px;color:{{$fg}};font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;">{{.label}}</span>
|
||||
{{- $fg := pick .tone "#4fb484" "#e2705a" "#d6a63f" "#5b9be8" "#9fb3ca"}}{{$bg := pick .tone "#173743" "#2d2d3d" "#2b3539" "#193352" "#102842"}}{{$bd := pick .tone "#245452" "#573d44" "#534f3a" "#284b75" "#1e3855" -}}
|
||||
<span style="display:inline-block;padding:4px 11px;background:{{$bg}};border:1px solid {{$bd}};border-radius:9999px;color:{{$fg}};font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;white-space:nowrap;">{{.label}}</span>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* callout takes dict "tone" "…" "title" "…" "text" "…": one tinted panel
|
||||
for the sentence the reader must not miss. */ -}}
|
||||
{{- define "callout" -}}
|
||||
{{- $fg := pick .tone "#4fb484" "#e2705a" "#d6a63f" "#5b9be8" "#9fb3ca"}}{{$bg := pick .tone "#173743" "#2d2d3d" "#2b3539" "#193352" "#102842"}}{{$bd := pick .tone "#245452" "#573d44" "#534f3a" "#284b75" "#1e3855" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 20px;background:{{$bg}};border:1px solid {{$bd}};border-radius:6px;border-collapse:separate;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px;">
|
||||
<p style="margin:0 0 4px;color:{{$fg}};font-size:13px;font-weight:700;">{{.title}}</p>
|
||||
<p style="margin:0;color:#c3d1e1;font-size:13px;line-height:1.6;">{{.text}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* steps takes a list of dict "t" "…" "d" "…". Numbered because the
|
||||
content is always an order the reader follows. */ -}}
|
||||
{{- define "steps" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;">
|
||||
{{- range $i, $s := .}}
|
||||
<tr>
|
||||
<td width="28" style="width:28px;padding:0 12px 14px 0;vertical-align:top;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
|
||||
<td width="26" height="26" align="center" style="width:26px;height:26px;border-radius:9999px;background:#193352;border:1px solid #284b75;color:#7fb2f0;font-size:12px;font-weight:700;line-height:26px;text-align:center;">{{add $i 1}}</td>
|
||||
</tr></table>
|
||||
</td>
|
||||
<td style="padding:3px 0 14px;vertical-align:top;">
|
||||
<p style="margin:0 0 2px;color:#e4ecf6;font-size:14px;font-weight:600;line-height:1.4;">{{$s.t}}</p>
|
||||
<p style="margin:0;color:#9fb3ca;font-size:13px;line-height:1.55;">{{$s.d}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</table>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* stats takes a list of dict "big" "…" "label" "…" "tone" "…": the
|
||||
figures a message exists to state, such as days left. */ -}}
|
||||
{{- define "stats" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;">
|
||||
<tr>
|
||||
{{- range $i, $s := .}}
|
||||
{{- if $i}}<td width="10" style="width:10px;font-size:0;"> </td>{{end}}
|
||||
<td style="padding:14px 16px;background:#102842;border:1px solid #1e3855;border-radius:6px;vertical-align:top;">
|
||||
<p style="margin:0;color:{{pick $s.tone "#4fb484" "#e2705a" "#d6a63f" "#7fb2f0" "#e4ecf6"}};font-size:26px;font-weight:700;line-height:1.15;letter-spacing:-.01em;">{{$s.big}}</p>
|
||||
<p style="margin:4px 0 0;color:#71879f;font-size:12px;line-height:1.4;">{{$s.label}}</p>
|
||||
</td>
|
||||
{{- end}}
|
||||
</tr>
|
||||
</table>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* timeline takes a list of dict "label" "…" "date" "…" "state"
|
||||
"done|now|next" "tone" "…". It is the licence lifecycle, drawn as
|
||||
segments so the reader sees where they are in it. */ -}}
|
||||
{{- define "timeline" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 22px;table-layout:fixed;">
|
||||
<tr>
|
||||
{{- range $i, $s := .}}
|
||||
{{- if $i}}<td width="6" style="width:6px;font-size:0;"> </td>{{end}}
|
||||
<td style="vertical-align:top;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td style="height:4px;line-height:4px;font-size:0;border-radius:2px;background:{{if eq $s.state "now"}}{{pick $s.tone "#4fb484" "#e2705a" "#d6a63f" "#5b9be8" "#5b9be8"}}{{else if eq $s.state "done"}}#3d5673{{else}}#1e3855{{end}};"> </td></tr></table>
|
||||
<p style="margin:10px 0 2px;color:{{if eq $s.state "now"}}{{pick $s.tone "#4fb484" "#e2705a" "#d6a63f" "#7fb2f0" "#7fb2f0"}}{{else}}#71879f{{end}};font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;">{{$s.label}}</p>
|
||||
<p style="margin:0;color:{{if eq $s.state "next"}}#9fb3ca{{else}}#e4ecf6{{end}};font-size:13px;font-weight:600;line-height:1.4;">{{$s.date}}</p>
|
||||
</td>
|
||||
{{- end}}
|
||||
</tr>
|
||||
</table>
|
||||
{{- end -}}
|
||||
|
||||
{{- /* findings takes a list of VulnDigestRow: one line per finding, the
|
||||
severity as a chip, the advisory linked when its ID says where it is. */ -}}
|
||||
{{- define "findings" -}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 16px;background:#102842;border:1px solid #1e3855;border-radius:6px;border-collapse:separate;">
|
||||
{{- range $i, $r := .}}
|
||||
<tr>
|
||||
<td style="padding:12px 16px;{{if $i}}border-top:1px solid #172c44;{{end}}vertical-align:top;">
|
||||
<p style="margin:0 0 6px;">{{template "chip" (dict "label" $r.Severity "tone" (sevTone $r.Severity))}} {{with advisoryURL $r.CVEID}}<a href="{{.}}" style="color:#7fb2f0;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:13px;font-weight:700;text-decoration:none;">{{$r.CVEID}}</a>{{else}}<span style="color:#e4ecf6;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:13px;font-weight:700;">{{$r.CVEID}}</span>{{end}}</p>
|
||||
<p style="margin:0;color:#9fb3ca;font-size:13px;line-height:1.5;"><span style="color:#e4ecf6;font-weight:600;">{{$r.PackageName}}</span> on {{$r.ServerName}}</p>
|
||||
</td>
|
||||
<td align="right" style="padding:12px 16px 12px 0;{{if $i}}border-top:1px solid #172c44;{{end}}vertical-align:top;white-space:nowrap;font-size:12px;font-weight:600;">
|
||||
{{- if $r.FixedIn}}<span style="color:#4fb484;">Fixed in {{$r.FixedIn}}</span>{{else}}<span style="color:#d6a63f;">No fix yet</span>{{end -}}
|
||||
</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</table>
|
||||
{{- end -}}
|
||||
|
||||
{{- $tone := tone -}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#071628;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#071628;padding:32px 12px;">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="supported-color-schemes" content="dark">
|
||||
<title>{{template "title" .}}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#071628;" bgcolor="#071628">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:#071628;font-size:1px;line-height:1px;">{{template "preheader" .}} ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#071628" style="background:#071628;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background:#0d2138;border:1px solid #1e3855;border-radius:4px;overflow:hidden;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||
<tr><td style="height:3px;background:#5b9be8;"></td></tr>
|
||||
<td align="center" style="padding:32px 12px 40px;">
|
||||
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||
|
||||
<tr>
|
||||
<td style="padding:26px 28px 8px;">
|
||||
<span style="font-size:16px;font-weight:700;letter-spacing:-.01em;color:#7fb2f0;">Vantage</span>
|
||||
<td style="padding:0 4px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="vertical-align:middle;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
|
||||
<td width="28" height="28" align="center" style="width:28px;height:28px;border-radius:7px;background:#5b9be8;color:#04101f;font-size:15px;font-weight:800;line-height:28px;text-align:center;">V</td>
|
||||
<td style="padding-left:10px;color:#e4ecf6;font-size:16px;font-weight:700;letter-spacing:-.01em;">Vantage</td>
|
||||
</tr></table>
|
||||
</td>
|
||||
<td align="right" style="vertical-align:middle;color:#71879f;font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;">{{template "category" .}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px 28px 26px;">
|
||||
{{template "pill" .}}
|
||||
<h1 style="margin:2px 0 14px;font-size:20px;font-weight:700;line-height:1.3;color:#e4ecf6;">{{template "title" .}}</h1>
|
||||
{{template "body" .}}
|
||||
<td>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0d2138;border:1px solid #1e3855;border-radius:10px;border-collapse:separate;overflow:hidden;">
|
||||
<tr><td style="height:4px;line-height:4px;font-size:0;background:{{pick $tone "#4fb484" "#e2705a" "#d6a63f" "#5b9be8" "#5b9be8"}};border-radius:10px 10px 0 0;"> </td></tr>
|
||||
<tr>
|
||||
<td style="padding:28px 32px 8px;background-color:#0d2138;background-image:linear-gradient(180deg,{{pick $tone "#173743" "#2d2d3d" "#2b3539" "#193352" "#193352"}} 0%,#0d2138 100%);">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 16px;"><tr>
|
||||
<td width="40" height="40" align="center" style="width:40px;height:40px;border-radius:9999px;background:{{pick $tone "#173743" "#2d2d3d" "#2b3539" "#193352" "#193352"}};border:1px solid {{pick $tone "#245452" "#573d44" "#534f3a" "#284b75" "#284b75"}};color:{{pick $tone "#4fb484" "#e2705a" "#d6a63f" "#7fb2f0" "#7fb2f0"}};font-size:18px;font-weight:800;line-height:40px;text-align:center;">{{pick $tone "✓" "!" "!" "→" "→"}}</td>
|
||||
<td style="padding-left:12px;vertical-align:middle;">{{template "pill" .}}</td>
|
||||
</tr></table>
|
||||
<h1 style="margin:0 0 6px;font-size:23px;font-weight:700;line-height:1.3;letter-spacing:-.01em;color:#e4ecf6;">{{template "title" .}}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 32px 12px;">
|
||||
{{template "body" .}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:16px 32px 18px;border-top:1px solid #1e3855;background:#0a1c30;border-radius:0 0 10px 10px;">
|
||||
<p style="margin:0;color:#71879f;font-size:12px;line-height:1.6;"><span style="color:#9fb3ca;font-weight:600;">Why you got this:</span> {{template "why" .}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:14px 28px;border-top:1px solid #1e3855;background:#04101f;">
|
||||
<p style="margin:0;color:#71879f;font-size:12px;line-height:1.5;">Sent by Vantage · infrastructure control plane</p>
|
||||
<td align="center" style="padding:22px 8px 0;">
|
||||
{{- with hq}}
|
||||
<p style="margin:0 0 10px;font-size:12px;line-height:1.6;">
|
||||
<a href="{{.}}" style="color:#9fb3ca;text-decoration:none;font-weight:600;">Vantage HQ</a>
|
||||
<span style="color:#3d5673;"> · </span>
|
||||
<a href="{{.}}/instances" style="color:#9fb3ca;text-decoration:none;font-weight:600;">Instances</a>
|
||||
<span style="color:#3d5673;"> · </span>
|
||||
<a href="{{.}}/billing" style="color:#9fb3ca;text-decoration:none;font-weight:600;">Billing</a>
|
||||
<span style="color:#3d5673;"> · </span>
|
||||
<a href="{{.}}/settings" style="color:#9fb3ca;text-decoration:none;font-weight:600;">Settings</a>
|
||||
</p>
|
||||
{{- end}}
|
||||
<p style="margin:0 0 4px;color:#71879f;font-size:12px;line-height:1.6;">Vantage · infrastructure control plane</p>
|
||||
<p style="margin:0;color:#4f6680;font-size:11px;line-height:1.6;">© {{year}} Vantage. This is an automated message about your service.</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
{{- /*
|
||||
The plain-text counterpart of layout.html.tmpl.
|
||||
|
||||
It defines the same helper names — p, lead, button, well, note, rows, chip —
|
||||
so a message's txt file reads as the same document as its html one, and a
|
||||
helper added on one side is obvious by its absence on the other.
|
||||
It defines the same helper names - p, lead, small, label, divider, button,
|
||||
well, note, rows, chip, callout, steps, stats, timeline - so a message's txt
|
||||
file reads as the same document as its html one, and a helper added on one
|
||||
side is obvious by its absence on the other.
|
||||
|
||||
"subject" is defined here rather than in the HTML file: html/template would
|
||||
escape an ampersand in an instance name, and mail clients show subject lines
|
||||
verbatim.
|
||||
"subject" and "tone" are defined here rather than in the HTML file:
|
||||
html/template would escape an ampersand in an instance name, and mail
|
||||
clients show subject lines verbatim. tone is read once per render and handed
|
||||
to both layouts through the tone func.
|
||||
*/ -}}
|
||||
{{- define "subject"}}Vantage{{end -}}
|
||||
{{- define "tone"}}accent{{end -}}
|
||||
{{- define "title"}}{{end -}}
|
||||
{{- define "pill"}}{{end -}}
|
||||
{{- define "body"}}{{end -}}
|
||||
{{- define "category"}}Account{{end -}}
|
||||
{{- define "why"}}it relates to your Vantage account.{{end -}}
|
||||
|
||||
{{- define "p"}}{{.}}
|
||||
|
||||
@@ -20,8 +25,16 @@
|
||||
{{- define "lead"}}{{.}}
|
||||
|
||||
{{end -}}
|
||||
{{- define "button"}}{{.label}}:
|
||||
{{- define "small"}}{{.}}
|
||||
|
||||
{{end -}}
|
||||
{{- define "label"}}{{upper .}}
|
||||
|
||||
{{end -}}
|
||||
{{- define "divider"}}----------------------------------------
|
||||
|
||||
{{end -}}
|
||||
{{- define "button"}}{{.label}}:
|
||||
{{.url}}
|
||||
|
||||
{{end -}}
|
||||
@@ -31,16 +44,38 @@
|
||||
{{- define "note"}}{{.}}
|
||||
|
||||
{{end -}}
|
||||
{{- define "rows"}}{{range .}}{{.k}}: {{.v}}
|
||||
{{- define "rows"}}{{range .}} {{.k}}: {{.v}}
|
||||
{{end}}
|
||||
{{end -}}
|
||||
{{- define "chip"}}[{{upper .label}}]
|
||||
{{- define "chip"}}[{{upper .label}}]{{end -}}
|
||||
{{- define "callout"}}>> {{.title}}
|
||||
{{.text}}
|
||||
|
||||
{{end -}}
|
||||
VANTAGE
|
||||
{{- define "steps"}}{{range $i, $s := .}} {{add $i 1}}. {{$s.t}}
|
||||
{{$s.d}}
|
||||
{{end}}
|
||||
{{end -}}
|
||||
{{- define "stats"}}{{range .}} {{.label}}: {{.big}}
|
||||
{{end}}
|
||||
{{end -}}
|
||||
{{- define "timeline"}}{{range .}} {{if eq .state "done"}}[x]{{else if eq .state "now"}}[>]{{else}}[ ]{{end}} {{.label}}: {{.date}}
|
||||
{{end}}
|
||||
{{end -}}
|
||||
{{- define "findings"}}{{range .}} - {{.CVEID}} [{{upper .Severity}}] {{.PackageName}} on {{.ServerName}}, {{if .FixedIn}}fixed in {{.FixedIn}}{{else}}no fix published yet{{end}}
|
||||
{{end}}
|
||||
{{end -}}
|
||||
VANTAGE · {{template "category" .}}
|
||||
|
||||
{{template "pill" .}}
|
||||
{{template "title" .}}
|
||||
========================================
|
||||
|
||||
{{template "body" .}}
|
||||
--
|
||||
Sent by Vantage · infrastructure control plane
|
||||
----------------------------------------
|
||||
Why you got this: {{template "why" .}}
|
||||
{{with hq}}
|
||||
Vantage HQ: {{.}}
|
||||
Billing: {{.}}/billing
|
||||
{{end}}
|
||||
Vantage · infrastructure control plane
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
{{define "title"}}Your licence key{{end}}
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "preheader"}}Paste this key into Settings, Licence on {{.InstanceName}}.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Licence key" "tone" "accent")}}{{end}}
|
||||
{{define "title"}}Your licence key for {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your licence for %s is below." .InstanceName)}}
|
||||
{{template "p" "Paste it into Settings → Licence on your Vantage install:"}}
|
||||
{{template "lead" (printf "Here is the signed licence for %s. It activates that install and no other." .InstanceName)}}
|
||||
{{template "label" "Install it in three steps"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Open your Vantage install" "d" "Sign in as an administrator.")
|
||||
(dict "t" "Go to Settings → Licence" "d" "The current licence and its expiry are shown there.")
|
||||
(dict "t" "Paste the key and save" "d" "Copy the whole block below, from the first character to the last."))}}
|
||||
{{template "label" "Licence key"}}
|
||||
{{template "well" .Blob}}
|
||||
{{template "p" "The licence is signed public data, not a secret — it is useless on any instance other than the one it names."}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Issued" "v" (stamp .Issued)))}}
|
||||
{{template "callout" (dict "tone" "accent" "title" "Safe to keep in email" "text" "The licence is signed public data, not a secret. It is useless on any instance other than the one it names.")}}
|
||||
{{end}}
|
||||
{{define "why"}}a licence was issued for {{.InstanceName}}.{{end}}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
{{define "subject"}}Your Vantage licence key{{end}}
|
||||
{{define "title"}}Your licence key{{end}}
|
||||
{{define "subject"}}Your Vantage licence key for {{.InstanceName}}{{end}}
|
||||
{{define "tone"}}accent{{end}}
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Licence key" "tone" "accent")}}{{end}}
|
||||
{{define "title"}}Your licence key for {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your licence for %s is below." .InstanceName)}}
|
||||
{{template "p" "Paste it into Settings > Licence on your Vantage install:"}}
|
||||
{{template "lead" (printf "Here is the signed licence for %s. It activates that install and no other." .InstanceName)}}
|
||||
{{template "label" "Install it in three steps"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Open your Vantage install" "d" "Sign in as an administrator.")
|
||||
(dict "t" "Go to Settings > Licence" "d" "The current licence and its expiry are shown there.")
|
||||
(dict "t" "Paste the key and save" "d" "Copy the whole block below, from the first character to the last."))}}
|
||||
{{template "label" "Licence key"}}
|
||||
{{template "well" .Blob}}
|
||||
{{template "p" "The licence is signed public data, not a secret — it is useless on any instance other than the one it names."}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Issued" "v" (stamp .Issued)))}}
|
||||
{{template "callout" (dict "tone" "accent" "title" "Safe to keep in email" "text" "The licence is signed public data, not a secret. It is useless on any instance other than the one it names.")}}
|
||||
{{end}}
|
||||
{{define "why"}}a licence was issued for {{.InstanceName}}.{{end}}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
{{define "category"}}Monitoring{{end}}
|
||||
{{define "preheader"}}{{.Type}} check {{if .Down}}is failing{{else}}is passing again{{end}}{{if .Message}}: {{.Message}}{{end}}{{end}}
|
||||
{{define "pill"}}{{if .Down}}{{template "chip" (dict "label" "Down" "tone" "down")}}{{else}}{{template "chip" (dict "label" "Recovered" "tone" "up")}}{{end}}{{end}}
|
||||
{{define "title"}}{{.MonitorName}}{{end}}
|
||||
{{define "title"}}{{.MonitorName}} {{if .Down}}is down{{else}}has recovered{{end}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "p" (printf "%s check" .Type)}}
|
||||
{{if .Message}}{{template "note" .Message}}{{end}}
|
||||
{{if .Down}}{{template "lead" (printf "The %s check for %s started failing at %s." .Type .MonitorName (stamp .Time))}}{{else}}{{template "lead" (printf "The %s check for %s is passing again as of %s." .Type .MonitorName (stamp .Time))}}{{end}}
|
||||
{{if .Message}}{{template "label" "Check output"}}{{template "note" .Message}}{{end}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Monitor" "v" .MonitorName)
|
||||
(dict "k" "Check type" "v" .Type)
|
||||
(dict "k" "Status" "v" (printf "%s → %s" .OldStatus .NewStatus))
|
||||
(dict "k" "Type" "v" .Type)
|
||||
(dict "k" "Time" "v" (stamp .Time)))}}
|
||||
(dict "k" "Changed at" "v" (stamp .Time)))}}
|
||||
{{if .Down}}{{template "callout" (dict "tone" "down" "title" "What to do" "text" "Open the monitor in your control plane to see recent samples and response times. You will get another email as soon as it recovers.")}}{{else}}{{template "callout" (dict "tone" "up" "title" "All clear" "text" "No action is needed. You will be emailed again if the check fails.")}}{{end}}
|
||||
{{end}}
|
||||
{{define "why"}}this address is on an email notification channel for this monitor.{{end}}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
{{define "subject"}}[Vantage] {{.MonitorName}} ({{.Type}}) {{if .Down}}is DOWN{{else}}recovered{{end}}{{if .Message}}: {{.Message}}{{end}}{{end}}
|
||||
{{define "tone"}}{{if .Down}}down{{else}}up{{end}}{{end}}
|
||||
{{define "category"}}Monitoring{{end}}
|
||||
{{define "pill"}}{{if .Down}}{{template "chip" (dict "label" "Down" "tone" "down")}}{{else}}{{template "chip" (dict "label" "Recovered" "tone" "up")}}{{end}}{{end}}
|
||||
{{define "title"}}{{.MonitorName}}{{end}}
|
||||
{{define "title"}}{{.MonitorName}} {{if .Down}}is down{{else}}has recovered{{end}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "p" (printf "%s check" .Type)}}
|
||||
{{if .Message}}{{template "note" .Message}}{{end}}
|
||||
{{if .Down}}{{template "lead" (printf "The %s check for %s started failing at %s." .Type .MonitorName (stamp .Time))}}{{else}}{{template "lead" (printf "The %s check for %s is passing again as of %s." .Type .MonitorName (stamp .Time))}}{{end}}
|
||||
{{if .Message}}{{template "label" "Check output"}}{{template "note" .Message}}{{end}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Monitor" "v" .MonitorName)
|
||||
(dict "k" "Check type" "v" .Type)
|
||||
(dict "k" "Status" "v" (printf "%s -> %s" .OldStatus .NewStatus))
|
||||
(dict "k" "Type" "v" .Type)
|
||||
(dict "k" "Time" "v" (stamp .Time)))}}
|
||||
(dict "k" "Changed at" "v" (stamp .Time)))}}
|
||||
{{if .Down}}{{template "callout" (dict "tone" "down" "title" "What to do" "text" "Open the monitor in your control plane to see recent samples and response times. You will get another email as soon as it recovers.")}}{{else}}{{template "callout" (dict "tone" "up" "title" "All clear" "text" "No action is needed. You will be emailed again if the check fails.")}}{{end}}
|
||||
{{end}}
|
||||
{{define "why"}}this address is on an email notification channel for this monitor.{{end}}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
{{define "category"}}Billing{{end}}
|
||||
{{define "preheader"}}Nothing has changed yet. Update your payment method to avoid interruption.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Payment failed" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Payment failed for {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "A payment for %s failed." .InstanceName)}}
|
||||
{{template "p" "Your instance is unaffected while the card is retried. Update your payment method from the billing portal."}}
|
||||
{{if .PortalURL}}{{template "button" (dict "label" "Open billing portal" "url" .PortalURL)}}{{end}}
|
||||
{{template "lead" (printf "A payment for %s did not go through. The card will be retried automatically." .InstanceName)}}
|
||||
{{template "callout" (dict "tone" "up" "title" "Your instance is unaffected" "text" "Servers, agents and monitors keep running while the payment is retried. The licence is untouched.")}}
|
||||
{{if .PortalURL}}{{template "button" (dict "label" "Update payment method" "url" (printf "%s/billing" .PortalURL))}}{{end}}
|
||||
{{template "label" "How to fix it"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Open billing in Vantage HQ" "d" "Sign in and go to Billing.")
|
||||
(dict "t" "Update your payment method" "d" "Add a new card, or correct the details of the current one.")
|
||||
(dict "t" "That is all" "d" "The next retry uses the updated details."))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Payment" "v" "Failed, retrying")
|
||||
(dict "k" "Licence" "v" "Unaffected"))}}
|
||||
{{end}}
|
||||
{{define "why"}}a payment for the {{.InstanceName}} subscription failed.{{end}}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
{{define "subject"}}Payment failed for your Vantage subscription{{end}}
|
||||
{{define "subject"}}Payment failed for your Vantage subscription ({{.InstanceName}}){{end}}
|
||||
{{define "tone"}}pend{{end}}
|
||||
{{define "category"}}Billing{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Payment failed" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Payment failed for {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "A payment for %s failed." .InstanceName)}}
|
||||
{{template "p" "Your instance is unaffected while the card is retried. Update your payment method from the billing portal."}}
|
||||
{{if .PortalURL}}{{template "button" (dict "label" "Open billing portal" "url" .PortalURL)}}{{end}}
|
||||
{{template "lead" (printf "A payment for %s did not go through. The card will be retried automatically." .InstanceName)}}
|
||||
{{template "callout" (dict "tone" "up" "title" "Your instance is unaffected" "text" "Servers, agents and monitors keep running while the payment is retried. The licence is untouched.")}}
|
||||
{{if .PortalURL}}{{template "button" (dict "label" "Update payment method" "url" (printf "%s/billing" .PortalURL))}}{{end}}
|
||||
{{template "label" "How to fix it"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Open billing in Vantage HQ" "d" "Sign in and go to Billing.")
|
||||
(dict "t" "Update your payment method" "d" "Add a new card, or correct the details of the current one.")
|
||||
(dict "t" "That is all" "d" "The next retry uses the updated details."))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Payment" "v" "Failed, retrying")
|
||||
(dict "k" "Licence" "v" "Unaffected"))}}
|
||||
{{end}}
|
||||
{{define "why"}}a payment for the {{.InstanceName}} subscription failed.{{end}}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "preheader"}}Licence extended to {{date .Expires}}. Nothing else changes.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Renewed" "tone" "up")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} is renewed{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your Free licence for %s now runs until %s." .InstanceName (date .Expires))}}
|
||||
{{template "p" "Nothing else changes — your servers, agents and monitors carry on as they were."}}
|
||||
{{template "stats" (list
|
||||
(dict "big" (plural (daysUntil .Expires) "day" "days") "label" "remaining on the licence" "tone" "up")
|
||||
(dict "big" (shortDate .Expires) "label" "new expiry date" "tone" "none"))}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Renewed" "date" "Today" "state" "done")
|
||||
(dict "label" "Active" "date" (printf "Until %s" (shortDate .Expires)) "state" "now" "tone" "up")
|
||||
(dict "label" "Reminder" "date" "A week before" "state" "next"))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Plan" "v" "Free")
|
||||
(dict "k" "Licence expires" "v" (date .Expires)))}}
|
||||
{{template "p" "Nothing else changes. Your servers, agents and monitors carry on as they were."}}
|
||||
{{end}}
|
||||
{{define "why"}}the licence for {{.InstanceName}} was renewed.{{end}}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
{{define "subject"}}{{.InstanceName}} renewed{{end}}
|
||||
{{define "subject"}}{{.InstanceName}} renewed until {{shortDate .Expires}}{{end}}
|
||||
{{define "tone"}}up{{end}}
|
||||
{{define "category"}}Licence{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Renewed" "tone" "up")}}{{end}}
|
||||
{{define "title"}}{{.InstanceName}} is renewed{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your Free licence for %s now runs until %s." .InstanceName (date .Expires))}}
|
||||
{{template "p" "Nothing else changes — your servers, agents and monitors carry on as they were."}}
|
||||
{{template "timeline" (list
|
||||
(dict "label" "Renewed" "date" "Today" "state" "done")
|
||||
(dict "label" "Active" "date" (printf "Until %s" (shortDate .Expires)) "state" "now" "tone" "up")
|
||||
(dict "label" "Reminder" "date" "A week before" "state" "next"))}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "Plan" "v" "Free")
|
||||
(dict "k" "Licence expires" "v" (date .Expires))
|
||||
(dict "k" "Remaining" "v" (plural (daysUntil .Expires) "day" "days")))}}
|
||||
{{template "p" "Nothing else changes. Your servers, agents and monitors carry on as they were."}}
|
||||
{{end}}
|
||||
{{define "why"}}the licence for {{.InstanceName}} was renewed.{{end}}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
{{define "category"}}Account{{end}}
|
||||
{{define "preheader"}}One click to confirm {{.Email}}. The link expires in {{.TTLHours}} hours.{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Action needed" "tone" "accent")}}{{end}}
|
||||
{{define "title"}}Confirm your email address{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" "Confirm this address to finish setting up your Vantage account."}}
|
||||
{{template "lead" (printf "Welcome to Vantage. Confirm that %s is your address and your account is ready to use." .Email)}}
|
||||
{{template "button" (dict "label" "Confirm email address" "url" .Link)}}
|
||||
{{template "p" (printf "The link works once and expires in %d hours." .TTLHours)}}
|
||||
{{template "p" "If you did not request this, ignore this email — nothing happens until the link is opened."}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Account email" "v" .Email)
|
||||
(dict "k" "Link expires" "v" (stamp .Expires))
|
||||
(dict "k" "Can be used" "v" "Once"))}}
|
||||
{{template "label" "What happens next"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Confirm your address" "d" "Open the link above. It works once, then it is spent.")
|
||||
(dict "t" "Sign in to Vantage HQ" "d" "Use this email address and the password you chose at sign-up.")
|
||||
(dict "t" "Create your first instance" "d" "It starts on a Free licence you can renew in one click."))}}
|
||||
{{template "callout" (dict "tone" "none" "title" "Did not sign up?" "text" "Ignore this email. Nothing is activated until the link is opened, and it expires on its own.")}}
|
||||
{{end}}
|
||||
{{define "why"}}someone signed up for Vantage with {{.Email}}.{{end}}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
{{define "subject"}}Verify your Vantage account{{end}}
|
||||
{{define "subject"}}Confirm your email to finish setting up Vantage{{end}}
|
||||
{{define "tone"}}accent{{end}}
|
||||
{{define "category"}}Account{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Action needed" "tone" "accent")}}{{end}}
|
||||
{{define "title"}}Confirm your email address{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" "Confirm this address to finish setting up your Vantage account."}}
|
||||
{{template "lead" (printf "Welcome to Vantage. Confirm that %s is your address and your account is ready to use." .Email)}}
|
||||
{{template "button" (dict "label" "Confirm email address" "url" .Link)}}
|
||||
{{template "p" (printf "The link works once and expires in %d hours." .TTLHours)}}
|
||||
{{template "p" "If you did not request this, ignore this email — nothing happens until the link is opened."}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Account email" "v" .Email)
|
||||
(dict "k" "Link expires" "v" (stamp .Expires))
|
||||
(dict "k" "Can be used" "v" "Once"))}}
|
||||
{{template "label" "What happens next"}}
|
||||
{{template "steps" (list
|
||||
(dict "t" "Confirm your address" "d" "Open the link above. It works once, then it is spent.")
|
||||
(dict "t" "Sign in to Vantage HQ" "d" "Use this email address and the password you chose at sign-up.")
|
||||
(dict "t" "Create your first instance" "d" "It starts on a Free licence you can renew in one click."))}}
|
||||
{{template "callout" (dict "tone" "none" "title" "Did not sign up?" "text" "Ignore this email. Nothing is activated until the link is opened, and it expires on its own.")}}
|
||||
{{end}}
|
||||
{{define "why"}}someone signed up for Vantage with {{.Email}}.{{end}}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
{{define "title"}}New vulnerabilities detected{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" (upper .TopSeverity) "tone" "down")}}{{end}}
|
||||
{{define "category"}}Security{{end}}
|
||||
{{define "preheader"}}{{.Summary}}{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" (printf "Highest: %s" .TopSeverity) "tone" (sevTone .TopSeverity))}}{{end}}
|
||||
{{define "title"}}{{plural .Count "new vulnerability" "new vulnerabilities"}} on {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" .Summary}}
|
||||
{{template "rows" (list
|
||||
(dict "k" "Instance" "v" .InstanceName)
|
||||
(dict "k" "New findings" "v" .Count))}}
|
||||
{{range .Rows}}
|
||||
{{if .FixedIn}}{{template "well" (printf "%s (%s) — %s on %s, fixed in %s" .CVEID .Severity .PackageName .ServerName .FixedIn)}}{{else}}{{template "well" (printf "%s (%s) — %s on %s, no fix published" .CVEID .Severity .PackageName .ServerName)}}{{end}}
|
||||
{{end}}
|
||||
{{if .More}}{{template "p" (printf "…and %d more." .More)}}{{end}}
|
||||
{{template "note" (printf "Scanned against a vulnerability database pulled %s ago." .DBAge)}}
|
||||
{{template "stats" (list
|
||||
(dict "big" .Count "label" "new findings" "tone" (sevTone .TopSeverity))
|
||||
(dict "big" .TopSeverity "label" "highest severity" "tone" (sevTone .TopSeverity))
|
||||
(dict "big" .DBAge "label" "database age" "tone" "none"))}}
|
||||
{{template "label" "Findings"}}
|
||||
{{template "findings" .Rows}}
|
||||
{{if .More}}{{template "small" (printf "Plus %d more not listed here. The full list is on the Vulnerabilities page of your control plane." .More)}}{{end}}
|
||||
{{template "callout" (dict "tone" "accent" "title" "What to do" "text" "Update each affected package to the fixed version shown. Findings marked No fix yet have no vendor patch published so far.")}}
|
||||
{{template "small" (printf "Scanned against a vulnerability database pulled %s ago." .DBAge)}}
|
||||
{{end}}
|
||||
{{define "why"}}this address is on a notification channel attached to a vulnerability alert rule on {{.InstanceName}}.{{end}}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
{{define "subject"}}{{.Count}} new {{if eq .Count 1}}vulnerability{{else}}vulnerabilities{{end}} on {{.InstanceName}}{{end}}
|
||||
{{define "title"}}New vulnerabilities detected{{end}}
|
||||
{{define "pill"}}{{.TopSeverity}}{{end}}
|
||||
{{define "subject"}}{{plural .Count "new vulnerability" "new vulnerabilities"}} on {{.InstanceName}} (highest: {{.TopSeverity}}){{end}}
|
||||
{{define "tone"}}{{sevTone .TopSeverity}}{{end}}
|
||||
{{define "category"}}Security{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" (printf "Highest: %s" .TopSeverity) "tone" (sevTone .TopSeverity))}}{{end}}
|
||||
{{define "title"}}{{plural .Count "new vulnerability" "new vulnerabilities"}} on {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" .Summary}}
|
||||
|
||||
{{range .Rows}}- {{.CVEID}} ({{.Severity}}) — {{.PackageName}} on {{.ServerName}}{{if .FixedIn}}, fixed in {{.FixedIn}}{{else}}, no fix published{{end}}
|
||||
{{template "stats" (list
|
||||
(dict "big" .Count "label" "New findings" "tone" (sevTone .TopSeverity))
|
||||
(dict "big" .TopSeverity "label" "Highest severity" "tone" (sevTone .TopSeverity))
|
||||
(dict "big" .DBAge "label" "Database age" "tone" "none"))}}
|
||||
{{template "label" "Findings"}}
|
||||
{{template "findings" .Rows}}
|
||||
{{if .More}}{{template "small" (printf "Plus %d more not listed here. The full list is on the Vulnerabilities page of your control plane." .More)}}{{end}}
|
||||
{{template "callout" (dict "tone" "accent" "title" "What to do" "text" "Update each affected package to the fixed version shown. Findings marked no fix published yet have no vendor patch so far.")}}
|
||||
{{template "small" (printf "Scanned against a vulnerability database pulled %s ago." .DBAge)}}
|
||||
{{end}}
|
||||
{{if .More}}...and {{.More}} more.{{end}}
|
||||
|
||||
Scanned against vulnerability database pulled {{.DBAge}} ago.
|
||||
{{end}}
|
||||
{{define "why"}}this address is on a notification channel attached to a vulnerability alert rule on {{.InstanceName}}.{{end}}
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ type VulnDigestRow struct {
|
||||
PackageName string
|
||||
ServerName string
|
||||
// FixedIn empty means no vendor fix has been published, which the template
|
||||
// says explicitly rather than leaving blank — it is a real state, not
|
||||
// says explicitly rather than leaving blank - it is a real state, not
|
||||
// missing data.
|
||||
FixedIn string
|
||||
}
|
||||
@@ -23,7 +23,7 @@ type VulnDigestRow struct {
|
||||
type VulnDigest struct {
|
||||
InstanceName string
|
||||
// Count is every newly opened finding in the batch, which may exceed
|
||||
// len(Rows) — Rows is capped and More carries the remainder.
|
||||
// len(Rows) - Rows is capped and More carries the remainder.
|
||||
Count int
|
||||
TopSeverity string
|
||||
Summary string
|
||||
|
||||
+9
-1
@@ -13,7 +13,7 @@ import (
|
||||
// keys, workflows, monitors and secrets. It is the unit a licence attaches to.
|
||||
//
|
||||
// A paying customer may hold several. That grouping is called an Account and
|
||||
// lives only in the admin control plane — this service never sees it.
|
||||
// lives only in the admin control plane - this service never sees it.
|
||||
type Instance struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
@@ -31,4 +31,12 @@ type Instance struct {
|
||||
LicenseBlob string `bson:"license_blob,omitempty" json:"-"`
|
||||
LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"`
|
||||
LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"`
|
||||
|
||||
// LockedAt and PurgeAfter are written by vantage-admin's cloudprov when the
|
||||
// account that owns this instance is under a dispute. LockedAt makes the
|
||||
// control plane refuse the instance everywhere; PurgeAfter, set only once a
|
||||
// dispute has failed, authorises the reaper to delete it. Both absent is the
|
||||
// normal state, and nothing but admin ever sets them.
|
||||
LockedAt *time.Time `bson:"locked_at,omitempty" json:"locked_at,omitempty"`
|
||||
PurgeAfter *time.Time `bson:"purge_after,omitempty" json:"purge_after,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestInstanceLockFieldsBSON(t *testing.T) {
|
||||
at := time.Date(2026, 9, 10, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
raw, err := bson.Marshal(Instance{InstanceID: "i1", LockedAt: &at, PurgeAfter: &at})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var set bson.M
|
||||
if err := bson.Unmarshal(raw, &set); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
for _, k := range []string{"locked_at", "purge_after"} {
|
||||
if _, ok := set[k]; !ok {
|
||||
t.Errorf("%s missing from %v", k, set)
|
||||
}
|
||||
}
|
||||
|
||||
raw, err = bson.Marshal(Instance{InstanceID: "i1"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var unset bson.M
|
||||
if err := bson.Unmarshal(raw, &unset); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
for _, k := range []string{"locked_at", "purge_after"} {
|
||||
if _, ok := unset[k]; ok {
|
||||
t.Errorf("%s written for an unlocked instance: %v", k, unset)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -31,7 +31,7 @@ type Settings struct {
|
||||
|
||||
// LocalLoginEnabled is a pointer because it is absent on every settings
|
||||
// document written before this feature existed, and a plain bool would read
|
||||
// absent as disabled — turning off password login for the entire fleet at
|
||||
// absent as disabled - turning off password login for the entire fleet at
|
||||
// upgrade. Nil means enabled.
|
||||
LocalLoginEnabled *bool `bson:"local_login_enabled,omitempty" json:"local_login_enabled,omitempty"`
|
||||
|
||||
@@ -43,7 +43,7 @@ type Settings struct {
|
||||
// APITokenMaxDays caps how long a newly created API token may live.
|
||||
//
|
||||
// A pointer for the same reason the retention fields are: absent must mean
|
||||
// the default, and the default here is no cap at all — never-expire tokens
|
||||
// the default, and the default here is no cap at all - never-expire tokens
|
||||
// are allowed until an instance decides otherwise, so an upgrade changes
|
||||
// nothing. Nil or 0 is no cap. A positive value refuses both a longer
|
||||
// expiry and a token with no expiry.
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ const (
|
||||
AuthOIDC = "oidc"
|
||||
// AuthHQ marks a user projected from a Vantage HQ account. Its role,
|
||||
// password and existence are owned by HQ, and the instance API refuses to
|
||||
// change any of them locally — a role editable in two places is a role with
|
||||
// change any of them locally - a role editable in two places is a role with
|
||||
// two answers.
|
||||
AuthHQ = "hq"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package vantage.v1;
|
||||
|
||||
option go_package="gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb";
|
||||
|
||||
service Vantage {
|
||||
rpc Register(RegisterRequest) returns (RegisterResponse);
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
|
||||
rpc ReportPackages(ReportPackagesRequest) returns (ReportPackagesResponse);
|
||||
rpc ReportWorkloads(ReportWorkloadsRequest) returns (ReportWorkloadsResponse);
|
||||
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
|
||||
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
|
||||
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
|
||||
// Bidirectional stream: agent sends auth once, server pushes commands.
|
||||
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
|
||||
rpc ProxyStream(stream ProxyClientMsg) returns (stream ProxyServerMsg);
|
||||
}
|
||||
|
||||
message RegisterRequest {
|
||||
string server_id = 1;
|
||||
string pre_reg_token = 2;
|
||||
string hostname = 3;
|
||||
string ip_address = 4;
|
||||
string os_info = 5;
|
||||
}
|
||||
|
||||
message RegisterResponse {
|
||||
string agent_token = 1;
|
||||
}
|
||||
|
||||
message SyncRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string agent_version = 3;
|
||||
}
|
||||
|
||||
message SyncResponse {
|
||||
repeated string public_keys = 1;
|
||||
|
||||
// collect_packages tells the agent whether this instance's licence grants
|
||||
// vulnerability scanning. False means do not collect at all: no gRPC body,
|
||||
// no document, no storage. The server re-checks on ReportPackages - this
|
||||
// flag is the optimisation, the server check is the boundary.
|
||||
//
|
||||
// Absent reads as false, which is the safe direction: an old server that
|
||||
// does not send it leaves agents collecting nothing.
|
||||
bool collect_packages = 2;
|
||||
}
|
||||
|
||||
// ReportPackages carries a server's installed package set.
|
||||
//
|
||||
// The agent calls twice at most. The first call sends only the hash; if the
|
||||
// server already holds that hash it answers need_full = false and the ~150KB
|
||||
// body is never sent. A machine's package set changes rarely, so almost every
|
||||
// hour costs one small message.
|
||||
message ReportPackagesRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string hash = 3;
|
||||
OSRelease os = 4;
|
||||
repeated InstalledPackage packages = 5; // empty on the offer call
|
||||
}
|
||||
|
||||
message ReportPackagesResponse {
|
||||
bool need_full = 1;
|
||||
}
|
||||
|
||||
message OSRelease {
|
||||
string family = 1;
|
||||
// version_id is not optional: Ubuntu 22.04 and 24.04 publish different fixed
|
||||
// versions for the same CVE, so a scan without it is guesswork.
|
||||
string version_id = 2;
|
||||
string arch = 3;
|
||||
}
|
||||
|
||||
message InstalledPackage {
|
||||
string name = 1;
|
||||
string version = 2;
|
||||
int32 epoch = 3;
|
||||
string arch = 4;
|
||||
// source_name is what the Debian and Ubuntu feeds are keyed on: one advisory
|
||||
// against "openssl" covers libssl3, openssl and libssl-dev.
|
||||
string source_name = 5;
|
||||
}
|
||||
|
||||
message UploadKeyRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string public_key = 3;
|
||||
string label = 4;
|
||||
string private_key = 5;
|
||||
}
|
||||
|
||||
message UploadKeyResponse {
|
||||
string key_id = 1;
|
||||
}
|
||||
|
||||
// CommandStream messages
|
||||
|
||||
message AgentMessage {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
oneof payload {
|
||||
AgentReady ready = 3;
|
||||
CommandResult result = 4;
|
||||
StepResult step_result = 5;
|
||||
StepOutputChunk step_output = 6;
|
||||
WorkloadLogsResult workload_logs_result = 7;
|
||||
}
|
||||
}
|
||||
|
||||
message AgentReady {
|
||||
|
||||
}
|
||||
|
||||
message CommandResult {
|
||||
string command_id = 1;
|
||||
bool success = 2;
|
||||
string message = 3;
|
||||
}
|
||||
|
||||
message PackageUpdate {
|
||||
string name = 1;
|
||||
string current_version = 2;
|
||||
string new_version = 3;
|
||||
}
|
||||
|
||||
message ReportUpdatesRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
repeated PackageUpdate updates = 3;
|
||||
}
|
||||
|
||||
message ReportUpdatesResponse {
|
||||
|
||||
}
|
||||
|
||||
message CPUReport {
|
||||
string model = 1;
|
||||
int32 cores = 2;
|
||||
double usage_pct = 3;
|
||||
double load1 = 4;
|
||||
}
|
||||
|
||||
message MemReport {
|
||||
uint64 total_bytes = 1;
|
||||
uint64 used_bytes = 2;
|
||||
}
|
||||
|
||||
message PartitionReport {
|
||||
string device = 1;
|
||||
string mountpoint = 2;
|
||||
string fstype = 3;
|
||||
uint64 total_bytes = 4;
|
||||
uint64 used_bytes = 5;
|
||||
}
|
||||
|
||||
message InventoryReport {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
bool include_static = 3;
|
||||
CPUReport cpu = 4;
|
||||
MemReport memory = 5;
|
||||
uint64 swap_total = 6;
|
||||
uint64 swap_used = 7;
|
||||
repeated PartitionReport partitions = 8;
|
||||
string kernel = 9;
|
||||
// Set on static snapshots only. The agent never reboots; it reports that one
|
||||
// is owed and leaves the decision to a person or a workflow.
|
||||
bool reboot_required = 10;
|
||||
}
|
||||
|
||||
message InventoryReportResponse {
|
||||
|
||||
}
|
||||
|
||||
message MonitorSpec {
|
||||
string monitor_id = 1;
|
||||
string type = 2;
|
||||
string url = 3;
|
||||
string host = 4;
|
||||
int32 port = 5;
|
||||
string method = 6;
|
||||
int32 expected_status = 7;
|
||||
string keyword = 8;
|
||||
int32 tls_warn_days = 9;
|
||||
int32 interval_sec = 10;
|
||||
int32 retries = 11;
|
||||
bool insecure = 12;
|
||||
}
|
||||
|
||||
message SyncMonitorsRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
}
|
||||
|
||||
message SyncMonitorsResponse {
|
||||
repeated MonitorSpec monitors = 1;
|
||||
}
|
||||
|
||||
message CheckResult {
|
||||
string monitor_id = 1;
|
||||
bool up = 2;
|
||||
int32 latency_ms = 3;
|
||||
string message = 4;
|
||||
int64 cert_expiry_unix = 5;
|
||||
}
|
||||
|
||||
message ReportChecksRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
repeated CheckResult results = 3;
|
||||
}
|
||||
|
||||
message ReportChecksResponse {
|
||||
|
||||
}
|
||||
|
||||
message ApplyUpdatesCmd {
|
||||
|
||||
}
|
||||
|
||||
message ServerCommand {
|
||||
string command_id = 1;
|
||||
oneof command {
|
||||
GenerateKeyCmd generate_key = 2;
|
||||
DeleteKeyCmd delete_key = 3;
|
||||
UpdateAgentCmd update_agent = 4;
|
||||
ApplyUpdatesCmd apply_updates = 5;
|
||||
RunStepCmd run_step = 6;
|
||||
CleanupWorkspaceCmd cleanup_workspace = 7;
|
||||
OpenProxyCmd open_proxy = 8;
|
||||
PingCmd ping = 9;
|
||||
RefreshWorkloadsCmd refresh_workloads = 10;
|
||||
ControlWorkloadCmd control_workload = 11;
|
||||
WorkloadLogsCmd workload_logs = 12;
|
||||
}
|
||||
}
|
||||
|
||||
// PingCmd is a liveness beat, carrying nothing and requiring no reply.
|
||||
//
|
||||
// It exists because gRPC keepalive cannot prove what the agent needs to know.
|
||||
// Behind an L7 proxy the agent's HTTP/2 connection terminates at the proxy, so
|
||||
// keepalive pings are answered by the proxy whether or not the server behind it
|
||||
// is still there. A pod that dies leaves the agent blocked in Recv on a stream
|
||||
// that will never produce another message and never error - commands are
|
||||
// dispatched into it and silently lost. Only traffic that originates at the
|
||||
// server itself distinguishes a live stream from an orphaned one.
|
||||
message PingCmd {
|
||||
}
|
||||
|
||||
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
|
||||
// directory once all steps on that server have finished.
|
||||
message CleanupWorkspaceCmd {
|
||||
string workspace_id = 1;
|
||||
}
|
||||
|
||||
message DeleteKeyCmd {
|
||||
string label = 1;
|
||||
}
|
||||
|
||||
message UpdateAgentCmd {
|
||||
string version = 1; // e.g. "1.2.3"
|
||||
string gitea_base_url = 2; // e.g. "https://gitea.example.com"
|
||||
}
|
||||
|
||||
message GenerateKeyCmd {
|
||||
string label = 1;
|
||||
string key_type = 2; // ed25519 | rsa | ecdsa (default: ed25519)
|
||||
int32 key_size = 3; // bits; used for rsa and ecdsa
|
||||
string passphrase = 4; // empty = no passphrase
|
||||
string comment = 5; // embedded in public key
|
||||
}
|
||||
|
||||
message RunStepCmd {
|
||||
string interpreter = 1; // "bash" | "powershell"
|
||||
string script = 2;
|
||||
map<string, string> env = 3;
|
||||
int32 timeout_seconds = 4;
|
||||
string workspace_id = 5;
|
||||
}
|
||||
|
||||
message StepResult {
|
||||
string command_id = 1;
|
||||
int32 exit_code = 2;
|
||||
string stdout = 3;
|
||||
string stderr = 4;
|
||||
map<string, string> output_env = 5;
|
||||
}
|
||||
|
||||
message StepOutputChunk {
|
||||
string command_id = 1;
|
||||
uint64 seq = 2;
|
||||
bytes data = 3;
|
||||
bool eof = 4;
|
||||
}
|
||||
|
||||
// OpenProxyCmd tells the agent to dial 127.0.0.1:port locally and relay that
|
||||
// connection back over a fresh ProxyStream identified by proxy_id.
|
||||
message OpenProxyCmd {
|
||||
string proxy_id = 1;
|
||||
uint32 port = 2;
|
||||
}
|
||||
|
||||
message ProxyOpen {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string proxy_id = 3;
|
||||
}
|
||||
|
||||
message ProxyClose { string reason = 1; }
|
||||
|
||||
message ProxyClientMsg {
|
||||
oneof payload {
|
||||
ProxyOpen open = 1; // first message only
|
||||
bytes data = 2;
|
||||
ProxyClose close = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ProxyServerMsg {
|
||||
oneof payload {
|
||||
bytes data = 1;
|
||||
ProxyClose close = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workload registry
|
||||
|
||||
// ReportWorkloads carries what a server is running.
|
||||
//
|
||||
// Offer-then-send, the same handshake as ReportPackages: the agent calls once
|
||||
// with workloads empty, and resends with the body only if need_full is set.
|
||||
message ReportWorkloadsRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string hash = 3;
|
||||
bool docker_ok = 4;
|
||||
string docker_error = 5;
|
||||
bool systemd_ok = 6;
|
||||
string systemd_error = 7;
|
||||
repeated Workload workloads = 8; // empty on the offer call
|
||||
// full marks the second call. It is not inferred from an empty workloads
|
||||
// list: a host running nothing sends an empty list as its full report.
|
||||
bool full = 9;
|
||||
}
|
||||
|
||||
message ReportWorkloadsResponse {
|
||||
bool need_full = 1;
|
||||
}
|
||||
|
||||
message Workload {
|
||||
string kind = 1; // "container" | "unit"
|
||||
string id = 2;
|
||||
string name = 3;
|
||||
string state = 4;
|
||||
string health = 5;
|
||||
string image = 6;
|
||||
string stack = 7;
|
||||
repeated string ports = 8;
|
||||
int32 restarts = 9;
|
||||
string started_at = 10; // RFC3339, empty when not running
|
||||
bool protected = 11;
|
||||
}
|
||||
|
||||
// RefreshWorkloadsCmd carries no payload back. It makes the agent report
|
||||
// immediately through ReportWorkloads, so there is exactly one writer for the
|
||||
// server_workloads collection rather than two arriving by different routes.
|
||||
message RefreshWorkloadsCmd {}
|
||||
|
||||
message ControlWorkloadCmd {
|
||||
string kind = 1;
|
||||
string id = 2;
|
||||
string action = 3; // "start" | "stop" | "restart"
|
||||
}
|
||||
|
||||
message WorkloadLogsCmd {
|
||||
string kind = 1;
|
||||
string id = 2;
|
||||
int32 tail = 3;
|
||||
}
|
||||
|
||||
message WorkloadLogsResult {
|
||||
string command_id = 1;
|
||||
string text = 2;
|
||||
bool truncated = 3;
|
||||
string error = 4;
|
||||
}
|
||||
@@ -30,8 +30,8 @@ func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*mode
|
||||
// created before payment and provisioning happens on the confirmed-payment
|
||||
// webhook. Provisioning with the placeholder's own ID keeps the id stable, so
|
||||
// the subscription's custom_data never points at a rewritten row and later
|
||||
// webhooks still resolve it. If an instance with this ID already exists — a
|
||||
// webhook retried after a partial provision — it is returned as-is rather than
|
||||
// webhooks still resolve it. If an instance with this ID already exists - a
|
||||
// webhook retried after a partial provision - it is returned as-is rather than
|
||||
// duplicated.
|
||||
//
|
||||
// The count-then-insert loop is racy on its own. It is safe only because
|
||||
@@ -92,7 +92,7 @@ var ErrSlugTaken = errors.New("slug taken")
|
||||
// the slug it holds now.
|
||||
//
|
||||
// It returns the current slug unchanged when the name still derives to it, so a
|
||||
// cosmetic edit — capitalisation, punctuation, a trailing "Ltd." — is not a move
|
||||
// cosmetic edit - capitalisation, punctuation, a trailing "Ltd." - is not a move
|
||||
// and cannot collide with the instance's own slug.
|
||||
func RenameSlug(name, currentSlug string) (string, error) {
|
||||
base, err := BaseSlug(name)
|
||||
@@ -110,13 +110,13 @@ func RenameSlug(name, currentSlug string) (string, error) {
|
||||
// It returns the name and slug the control plane held BEFORE the write, and
|
||||
// those are the only correct values to unwind with. The caller's own copy of the
|
||||
// instance may be stale, and admin's copy stores slug with `omitempty`, so an
|
||||
// unwind driven from there can write an empty slug — which either mis-restores
|
||||
// unwind driven from there can write an empty slug - which either mis-restores
|
||||
// the tenant host or trips the unique index against every other slugless row.
|
||||
//
|
||||
// The count-then-update is racy on its own, and is safe for the same reason
|
||||
// CreateInstanceWithID's loop is: instances.slug carries a unique index, so a
|
||||
// lost race surfaces as a duplicate-key error. Unlike creation there is nothing
|
||||
// to retry with — the caller asked for one specific name — so it becomes
|
||||
// to retry with - the caller asked for one specific name - so it becomes
|
||||
// ErrSlugTaken. Do not remove the duplicate-key branch, and do not remove the
|
||||
// index.
|
||||
func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (inst *models.Instance, prevName, prevSlug string, err error) {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
// plane and sitesvc.
|
||||
//
|
||||
// These rules used to be duplicated: the control plane owned one copy and
|
||||
// sitesvc mirrored it by hand. The copies had already drifted — sitesvc retried
|
||||
// sitesvc mirrored it by hand. The copies had already drifted - sitesvc retried
|
||||
// on a lost slug race while the control plane returned an error. This package
|
||||
// is the single definition; neither service may reimplement any of it.
|
||||
package provision
|
||||
|
||||
Reference in New Issue
Block a user