| new file mode 100644 |
| index 000000000..70e63ce50 |
| --- /dev/null |
| +++ b/internal/mail/sender.go |
| @@ -0,0 +1,116 @@ |
| +// The sender (senders design section 5): a From address, a credential, a |
| +// cap, a pause and a set of counts, owned by one record. |
| +// |
| +// Before this file an app had exactly one implicit sender and everything was |
| +// keyed by (account, app): email.json, the IAM user, the DailyCap, the pause, |
| +// the mail_counts rows. Two requests do not fit that shape (#147 sends as an |
| +// address under an identity CARLOS does not own, #148 gives one instance per |
| +// customer its own domain, credential, cap and pause), so the sender becomes |
| +// a thing with a name. This step gives every app exactly ONE sender, "app", |
| +// which is what it already had. Nothing here creates a second one. |
| +package mail |
| + |
| +import ( |
| + "regexp" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The two scopes and the two sources a sender can have. Scope says what the |
| +// sender belongs to; Source says where its authority to send comes from. An |
| +// app sender on a domain the account verified is the only combination this |
| +// release produces; the others are named here because SenderNames, the |
| +// status reply and the caller index all branch on them, and a closed set |
| +// that is written down once is a set nothing invents a third member of. |
| +const ( |
| + ScopeApp = "app" |
| + ScopeInstance = "instance" |
| + |
| + SourceDomain = "domain" |
| + SourceAddress = "address" |
| +) |
| + |
| +// SenderAppID is the id of the sender every app has always had. It is a |
| +// constant rather than a literal because it is written into every migrated |
| +// record, read back by SenderOfCaller and compared in a dozen places. |
| +const SenderAppID = "app" |
| + |
| +// PauseKindPerInstance is the pause an app sender carries once its app has |
| +// moved to per-instance senders (design section 6.3). It is never a cap pause and |
| +// never auto-resumes: resumeStaleCapPause reactivates every key it touches, |
| +// and re-arming the app credential that every instance still holds is |
| +// exactly what per-instance mode exists to prevent. Nothing in this release |
| +// sets it; the refusals that read it are in place so step 5 cannot land |
| +// without them. |
| +const PauseKindPerInstance = "per-instance" |
| + |
| +// Sender is apps/<acct>/<app>/email/senders/<id>.json, one object per |
| +// sender, each with its own CAS etag so two writers never contend over a |
| +// sibling's state. |
| +type Sender struct { |
| + Schema int `json:"schema"` |
| + ID string `json:"id"` // "app", or provision.InstanceID(host) |
| + Slug string `json:"slug"` // the AWS-safe name component, see SenderNames |
| + Scope string `json:"scope"` // ScopeApp | ScopeInstance |
| + Host string `json:"host,omitempty"` |
| + Source string `json:"source"` // SourceDomain | SourceAddress |
| + |
| + // The four blocks keep their current shapes and move inside the sender |
| + // unchanged, which is what lets a legacy email.json decode straight into |
| + // one and what keeps every walk in walk.go compiling against Record. |
| + Declared *Declared `json:"declared,omitempty"` |
| + Grant *GrantBlock `json:"grant,omitempty"` |
| + Provisioned *Provisioned `json:"provisioned,omitempty"` |
| + Standalone []*Standalone `json:"standalone,omitempty"` |
| + |
| + Identity string `json:"identity,omitempty"` // external identity ARN (#147, step 3) |
| + Address string `json:"address,omitempty"` // the exact address, lowercased (#147, step 3) |
| + TenantARN string `json:"tenant_arn,omitempty"` // from CreateTenant |
| + |
| + // PerInstance marks an app sender whose app has moved to per-instance |
| + // senders (design section 6.3). Nothing in this release sets it. It lives on |
| + // the app sender rather than being derived from a prefix listing |
| + // because a listing cannot decide the question without racing an |
| + // app-sender enable, and the transition has to be one CAS write. |
| + PerInstance bool `json:"per_instance,omitempty"` |
| + |
| + // Salt disambiguates a slug collision. The slug is 40 bits of a hash, so |
| + // two senders can in principle want the same IAM user name; the caller |
| + // index detects that and ResolveSenderNames recomputes the slug with a |
| + // fresh salt, which has to be stored or the name would not be stable |
| + // across the next enable. |
| + Salt string `json:"salt,omitempty"` |
| +} |
| + |
| +// SendersPrefix is where an app's senders live. Inside apps/<acct>/<app>/ on |
| +// purpose: release.PurgeApp's prefix sweep reaches it, so a purged app |
| +// leaves no sender behind. |
| +func SendersPrefix(acct, app string) string { |
| + return release.AppPrefix(acct, app) + "email/senders/" |
| +} |
| + |
| +func SenderKey(acct, app, id string) string { return SendersPrefix(acct, app) + id + ".json" } |
| + |
| +// senderIDRe is deliberately stricter than a host name: this string becomes |
| +// a bucket key segment and half an IAM user name, and the only safe place to |
| +// refuse a separator or a traversal is the one gate every caller passes |
| +// through. It accepts a host (provision.InstanceID returns the host itself |
| +// when it is short enough) and the underscore-prefixed digest form |
| +// InstanceID falls back to for a long one. |
| +var senderIDRe = regexp.MustCompile(`^_?[a-z0-9]([a-z0-9.-]{0,62}[a-z0-9])?$`) |
| + |
| +// ValidSenderID reports whether id is usable as a sender id. ".json" is |
| +// refused along with every other dotted suffix that would collide with the |
| +// key's own extension only incidentally: what actually refuses it is the |
| +// check below, which is why it is spelled out. |
| +func ValidSenderID(id string) bool { |
| + if id == "" || len(id) > 64 || id == "." || id == ".." { |
| + return false |
| + } |
| + if !senderIDRe.MatchString(id) { |
| + return false |
| + } |
| + return !hasJSONSuffix(id) |
| +} |
| + |
| +func hasJSONSuffix(id string) bool { return len(id) > 5 && id[len(id)-5:] == ".json" } |