Files
warmbly/internal/app/instancesettings/document.go
T

248 lines
8.8 KiB
Go

package instancesettings
import (
"time"
"github.com/warmbly/warmbly/internal/config"
)
// Bounds on the invitation lifetime. An hour is the shortest window a person
// can realistically act in; 30 days is the longest a bearer link should live.
const (
TTLHoursMin = 1
TTLHoursMax = 720
TTLHoursDefault = 168
)
// Invitations holds the invite-flow knobs.
type Invitations struct {
// LinksEnabled exposes the copyable invite link. It is a bearer
// credential, so a regulated deployment turns it off and relies on mail.
LinksEnabled bool `json:"links_enabled"`
// TTLHours is how long an invitation stays valid.
TTLHours int `json:"ttl_hours"`
}
// Access holds the account-creation knobs.
type Access struct {
// AllowInvitedSignup lets the invitee create their own account. Off means
// an admin must create it for them.
AllowInvitedSignup bool `json:"allow_invited_signup"`
}
// Sync holds the mailbox sync fair-use budgets. Zero means "compiled
// default" on read, so a document written before the section existed still
// resolves; the accepted ranges are clamped in Normalize.
type Sync struct {
// BackfillDays is how far back the initial import reaches when a mailbox
// is connected.
BackfillDays int `json:"backfill_days"`
// BackfillMessages caps how many messages that initial import stores per
// mailbox, newest first.
BackfillMessages int `json:"backfill_messages"`
// DailyMessagesPerMailbox caps new (live) messages stored per mailbox per
// UTC day. Replies to the mailbox's own sends have their own equal budget.
DailyMessagesPerMailbox int `json:"daily_messages_per_mailbox"`
// DailyMessagesPerOrg caps new plus backfilled messages stored across one
// organization per UTC day.
DailyMessagesPerOrg int `json:"daily_messages_per_org"`
}
// Bounds on the domain-authentication grace window. One hour is the shortest
// window that still absorbs a resolver blip; 30 days is the longest a domain
// should keep sending cold mail unauthenticated while being warned about it.
const (
AuthGraceHoursMin = 1
AuthGraceHoursMax = 720
AuthGraceHoursDefault = 72
)
// Deliverability holds the sending-domain authentication gate.
type Deliverability struct {
// EnforceDomainAuth stops cold sends and warmup sends from a mailbox whose
// sending domain has been failing SPF/DMARC for longer than the grace
// window. Off leaves the state observe-only: still checked, still shown,
// still raised by the advisor, but never blocking.
EnforceDomainAuth bool `json:"enforce_domain_auth"`
// AuthGraceHours is how long a domain must stay failing before the gate
// applies. The clock starts when the sweep first sees the failure, so this
// is also how much warning the owner gets.
AuthGraceHours int `json:"auth_grace_hours"`
}
// Document is the whole Tier B settings document. Every key here is one no
// environment variable owns, so there is no precedence to resolve.
type Document struct {
Invitations Invitations `json:"invitations"`
Access Access `json:"access"`
Sync Sync `json:"sync"`
Deliverability Deliverability `json:"deliverability"`
}
// Defaults is the document a fresh instance behaves as if it had.
func Defaults() Document {
return Document{
Invitations: Invitations{
LinksEnabled: true,
TTLHours: TTLHoursDefault,
},
Access: Access{
AllowInvitedSignup: true,
},
Sync: DefaultSync(),
Deliverability: DefaultDeliverability(),
}
}
// DefaultDeliverability enforces the gate. Unauthenticated cold mail is a hard
// Gmail/Yahoo/Outlook rejection and the fastest way to burn a shared warmup
// pool, and the grace window means nothing stops until a domain has been
// provably broken for three days with the owner notified throughout.
func DefaultDeliverability() Deliverability {
return Deliverability{
EnforceDomainAuth: true,
AuthGraceHours: AuthGraceHoursDefault,
}
}
// DefaultSync is the compiled sync fair-use budget.
func DefaultSync() Sync {
return Sync{
BackfillDays: config.SyncBackfillDaysDefault,
BackfillMessages: config.SyncBackfillMessagesDefault,
DailyMessagesPerMailbox: config.SyncDailyMessagesMailboxDefault,
DailyMessagesPerOrg: config.SyncDailyMessagesOrgDefault,
}
}
// Normalize clamps a document into its accepted range. It is applied on read
// as well as on write, so a row written by an older version still resolves.
func (d *Document) Normalize() {
if d.Invitations.TTLHours <= 0 {
d.Invitations.TTLHours = TTLHoursDefault
}
if d.Invitations.TTLHours < TTLHoursMin {
d.Invitations.TTLHours = TTLHoursMin
}
if d.Invitations.TTLHours > TTLHoursMax {
d.Invitations.TTLHours = TTLHoursMax
}
d.Sync.Normalize()
d.Deliverability.Normalize()
}
// Normalize clamps the grace window. Zero and negative resolve to the compiled
// default rather than to "no grace": a document that predates this section, or
// one edited by hand, must not accidentally mean "gate everything immediately".
func (d *Deliverability) Normalize() {
if d.AuthGraceHours <= 0 {
d.AuthGraceHours = AuthGraceHoursDefault
}
if d.AuthGraceHours < AuthGraceHoursMin {
d.AuthGraceHours = AuthGraceHoursMin
}
if d.AuthGraceHours > AuthGraceHoursMax {
d.AuthGraceHours = AuthGraceHoursMax
}
}
// AuthGrace is the domain-authentication grace window as a duration.
func (d Deliverability) AuthGrace() time.Duration {
return time.Duration(d.AuthGraceHours) * time.Hour
}
// Normalize clamps every budget into its accepted range; zero and negative
// resolve to the compiled default rather than to "off". There is no way to
// switch fair use off from this document: an operator who wants no cap sets
// the maximum, which is still a number.
func (s *Sync) Normalize() {
clamp := func(v, def, max int) int {
if v <= 0 {
return def
}
if v > max {
return max
}
return v
}
s.BackfillDays = clamp(s.BackfillDays, config.SyncBackfillDaysDefault, config.SyncBackfillDaysMax)
s.BackfillMessages = clamp(s.BackfillMessages, config.SyncBackfillMessagesDefault, config.SyncBackfillMessagesMax)
s.DailyMessagesPerMailbox = clamp(s.DailyMessagesPerMailbox, config.SyncDailyMessagesMailboxDefault, config.SyncDailyMessagesMailboxMax)
s.DailyMessagesPerOrg = clamp(s.DailyMessagesPerOrg, config.SyncDailyMessagesOrgDefault, config.SyncDailyMessagesOrgMax)
}
// TTL is the invitation lifetime as a duration.
func (d Document) TTL() time.Duration {
return time.Duration(d.Invitations.TTLHours) * time.Hour
}
// Patch is a partial update. Absent fields keep their stored value, so a
// client that does not know about a key cannot clear it.
type Patch struct {
Invitations *struct {
LinksEnabled *bool `json:"links_enabled"`
TTLHours *int `json:"ttl_hours"`
} `json:"invitations"`
Access *struct {
AllowInvitedSignup *bool `json:"allow_invited_signup"`
} `json:"access"`
Sync *struct {
BackfillDays *int `json:"backfill_days"`
BackfillMessages *int `json:"backfill_messages"`
DailyMessagesPerMailbox *int `json:"daily_messages_per_mailbox"`
DailyMessagesPerOrg *int `json:"daily_messages_per_org"`
} `json:"sync"`
Deliverability *struct {
EnforceDomainAuth *bool `json:"enforce_domain_auth"`
AuthGraceHours *int `json:"auth_grace_hours"`
} `json:"deliverability"`
}
// Apply merges a patch onto a document.
func (p Patch) Apply(doc Document) Document {
if p.Invitations != nil {
if p.Invitations.LinksEnabled != nil {
doc.Invitations.LinksEnabled = *p.Invitations.LinksEnabled
}
if p.Invitations.TTLHours != nil {
// An explicit zero is a caller mistake, not "use the default": the
// documented accepted range starts at one hour.
doc.Invitations.TTLHours = *p.Invitations.TTLHours
if doc.Invitations.TTLHours < TTLHoursMin {
doc.Invitations.TTLHours = TTLHoursMin
}
}
}
if p.Access != nil && p.Access.AllowInvitedSignup != nil {
doc.Access.AllowInvitedSignup = *p.Access.AllowInvitedSignup
}
if p.Sync != nil {
if p.Sync.BackfillDays != nil {
doc.Sync.BackfillDays = *p.Sync.BackfillDays
}
if p.Sync.BackfillMessages != nil {
doc.Sync.BackfillMessages = *p.Sync.BackfillMessages
}
if p.Sync.DailyMessagesPerMailbox != nil {
doc.Sync.DailyMessagesPerMailbox = *p.Sync.DailyMessagesPerMailbox
}
if p.Sync.DailyMessagesPerOrg != nil {
doc.Sync.DailyMessagesPerOrg = *p.Sync.DailyMessagesPerOrg
}
}
if p.Deliverability != nil {
if p.Deliverability.EnforceDomainAuth != nil {
doc.Deliverability.EnforceDomainAuth = *p.Deliverability.EnforceDomainAuth
}
if p.Deliverability.AuthGraceHours != nil {
// An explicit zero is a caller mistake, not "no grace": the
// documented accepted range starts at one hour.
doc.Deliverability.AuthGraceHours = *p.Deliverability.AuthGraceHours
if doc.Deliverability.AuthGraceHours < AuthGraceHoursMin {
doc.Deliverability.AuthGraceHours = AuthGraceHoursMin
}
}
}
return doc
}