| 1 | // The sender (senders design section 5): a From address, a credential, a |
| 2 | // cap, a pause and a set of counts, owned by one record. |
| 3 | // |
| 4 | // Before this file an app had exactly one implicit sender and everything was |
| 5 | // keyed by (account, app): email.json, the IAM user, the DailyCap, the pause, |
| 6 | // the mail_counts rows. Two requests do not fit that shape (#147 sends as an |
| 7 | // address under an identity CARLOS does not own, #148 gives one instance per |
| 8 | // customer its own domain, credential, cap and pause), so the sender becomes |
| 9 | // a thing with a name. This step gives every app exactly ONE sender, "app", |
| 10 | // which is what it already had. Nothing here creates a second one. |
| 11 | package mail |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "crypto/rand" |
| 16 | "crypto/sha256" |
| 17 | "encoding/base32" |
| 18 | "encoding/hex" |
| 19 | "encoding/json" |
| 20 | "errors" |
| 21 | "fmt" |
| 22 | "log" |
| 23 | "os" |
| 24 | "regexp" |
| 25 | "strings" |
| 26 | "time" |
| 27 | |
| 28 | "github.com/carlosframework/platform/internal/release" |
| 29 | ) |
| 30 | |
| 31 | // The two scopes and the two sources a sender can have. Scope says what the |
| 32 | // sender belongs to; Source says where its authority to send comes from. An |
| 33 | // app sender on a domain the account verified is the only combination this |
| 34 | // release produces; the others are named here because SenderNames, the |
| 35 | // status reply and the caller index all branch on them, and a closed set |
| 36 | // that is written down once is a set nothing invents a third member of. |
| 37 | const ( |
| 38 | ScopeApp = "app" |
| 39 | ScopeInstance = "instance" |
| 40 | |
| 41 | SourceDomain = "domain" |
| 42 | SourceAddress = "address" |
| 43 | ) |
| 44 | |
| 45 | // SenderAppID is the id of the sender every app has always had. It is a |
| 46 | // constant rather than a literal because it is written into every migrated |
| 47 | // record, read back by SenderOfCaller and compared in a dozen places. |
| 48 | const SenderAppID = "app" |
| 49 | |
| 50 | // PauseKindPerInstance is the pause an app sender carries once its app has |
| 51 | // moved to per-instance senders (design section 6.3). It is never a cap pause and |
| 52 | // never auto-resumes: resumeStaleCapPause reactivates every key it touches, |
| 53 | // and re-arming the app credential that every instance still holds is |
| 54 | // exactly what per-instance mode exists to prevent. Nothing in this release |
| 55 | // sets it; the refusals that read it are in place so step 5 cannot land |
| 56 | // without them. |
| 57 | const PauseKindPerInstance = "per-instance" |
| 58 | |
| 59 | // Sender is apps/<acct>/<app>/email/senders/<id>.json, one object per |
| 60 | // sender, each with its own CAS etag so two writers never contend over a |
| 61 | // sibling's state. |
| 62 | type Sender struct { |
| 63 | Schema int `json:"schema"` |
| 64 | ID string `json:"id"` // "app", or provision.InstanceID(host) |
| 65 | Slug string `json:"slug"` // the AWS-safe name component, see SenderNames |
| 66 | Scope string `json:"scope"` // ScopeApp | ScopeInstance |
| 67 | Host string `json:"host,omitempty"` |
| 68 | Source string `json:"source"` // SourceDomain | SourceAddress |
| 69 | |
| 70 | // The four blocks keep their current shapes and move inside the sender |
| 71 | // unchanged, which is what lets a legacy email.json decode straight into |
| 72 | // one and what keeps every walk in walk.go compiling against Record. |
| 73 | Declared *Declared `json:"declared,omitempty"` |
| 74 | Grant *GrantBlock `json:"grant,omitempty"` |
| 75 | Provisioned *Provisioned `json:"provisioned,omitempty"` |
| 76 | Standalone []*Standalone `json:"standalone,omitempty"` |
| 77 | |
| 78 | Identity string `json:"identity,omitempty"` // external identity ARN (#147, step 3) |
| 79 | Address string `json:"address,omitempty"` // the exact address, lowercased (#147, step 3) |
| 80 | TenantARN string `json:"tenant_arn,omitempty"` // from CreateTenant |
| 81 | |
| 82 | // PerInstance marks an app sender whose app has moved to per-instance |
| 83 | // senders (design section 6.3). Nothing in this release sets it. It lives on |
| 84 | // the app sender rather than being derived from a prefix listing |
| 85 | // because a listing cannot decide the question without racing an |
| 86 | // app-sender enable, and the transition has to be one CAS write. |
| 87 | PerInstance bool `json:"per_instance,omitempty"` |
| 88 | |
| 89 | // Salt disambiguates a slug collision. The slug is 40 bits of a hash, so |
| 90 | // two senders can in principle want the same IAM user name; the caller |
| 91 | // index detects that and ResolveSenderNames recomputes the slug with a |
| 92 | // fresh salt, which has to be stored or the name would not be stable |
| 93 | // across the next enable. |
| 94 | Salt string `json:"salt,omitempty"` |
| 95 | } |
| 96 | |
| 97 | // SendersPrefix is where an app's senders live. Inside apps/<acct>/<app>/ on |
| 98 | // purpose: release.PurgeApp's prefix sweep reaches it, so a purged app |
| 99 | // leaves no sender behind. |
| 100 | func SendersPrefix(acct, app string) string { |
| 101 | return release.AppPrefix(acct, app) + "email/senders/" |
| 102 | } |
| 103 | |
| 104 | func SenderKey(acct, app, id string) string { return SendersPrefix(acct, app) + id + ".json" } |
| 105 | |
| 106 | // senderIDRe is deliberately stricter than a host name: this string becomes |
| 107 | // a bucket key segment and half an IAM user name, and the only safe place to |
| 108 | // refuse a separator or a traversal is the one gate every caller passes |
| 109 | // through. It accepts a host (provision.InstanceID returns the host itself |
| 110 | // when it is short enough) and the underscore-prefixed digest form |
| 111 | // InstanceID falls back to for a long one. |
| 112 | var senderIDRe = regexp.MustCompile(`^_?[a-z0-9]([a-z0-9.-]{0,62}[a-z0-9])?$`) |
| 113 | |
| 114 | // ValidSenderID reports whether id is usable as a sender id. ".json" is |
| 115 | // refused along with every other dotted suffix that would collide with the |
| 116 | // key's own extension only incidentally: what actually refuses it is the |
| 117 | // check below, which is why it is spelled out. |
| 118 | func ValidSenderID(id string) bool { |
| 119 | if id == "" || len(id) > 64 || id == "." || id == ".." { |
| 120 | return false |
| 121 | } |
| 122 | if !senderIDRe.MatchString(id) { |
| 123 | return false |
| 124 | } |
| 125 | return !hasJSONSuffix(id) |
| 126 | } |
| 127 | |
| 128 | func hasJSONSuffix(id string) bool { return len(id) > 5 && id[len(id)-5:] == ".json" } |
| 129 | |
| 130 | // The AWS bounds this file exists to respect. IAM caps a user name at 64 |
| 131 | // characters and SES caps a tenant name and a configuration-set name at 64 |
| 132 | // of [A-Za-z0-9_-]. The first draft of the senders design got this wrong: |
| 133 | // carlos-mail-<acct>-<app>+i+<instance-id> reaches 104 characters when the |
| 134 | // instance id is the host itself, which it is for any host of 40 characters |
| 135 | // or less. |
| 136 | const ( |
| 137 | iamUserNameMax = 64 |
| 138 | sesNameMax = 64 |
| 139 | // senderAppComponentMax truncates the app name inside a slugged sender's |
| 140 | // names. 12 + 16 + 1 + 24 + 3 + 8 = 64 exactly, at the longest legal |
| 141 | // account and app. Uniqueness comes from the hash, not from the app |
| 142 | // component, so truncating it costs nothing but readability. |
| 143 | senderAppComponentMax = 24 |
| 144 | // senderSlugLen is 40 bits of base32. Short enough to fit, long enough |
| 145 | // that a collision is rare, and detected either way by the caller index. |
| 146 | senderSlugLen = 8 |
| 147 | ) |
| 148 | |
| 149 | // ErrNameTooLong is the refusal at create for a name no AWS bound can hold. |
| 150 | // It is refused rather than truncated because a truncated name would |
| 151 | // collide silently with its neighbour, and because the only name this can |
| 152 | // happen to today is a standalone credential's, whose name the member chose |
| 153 | // and can choose differently. |
| 154 | var ErrNameTooLong = errors.New("mail: name exceeds the AWS bound") |
| 155 | |
| 156 | // SenderNameSet is every AWS name one sender owns. |
| 157 | type SenderNameSet struct { |
| 158 | Slug string // "" for the app sender, 8 base32 characters otherwise |
| 159 | IAMUser string |
| 160 | Tenant string |
| 161 | ConfigSet string |
| 162 | } |
| 163 | |
| 164 | // SenderNames is THE rule for what a sender's AWS resources are called, and |
| 165 | // there is exactly one of it so that a bounds test can enumerate every form. |
| 166 | // |
| 167 | // The app sender's names are unchanged, deliberately and permanently: |
| 168 | // carlos-mail-<acct>-<app>, tenant and configuration set carlos-<acct>. |
| 169 | // Renaming an SES tenant means destroying it and recreating it along with |
| 170 | // every association it carries, and rewriting an IAM user name means a new |
| 171 | // principal with the old one left live and stale. So the rule is that |
| 172 | // existing shapes never change and a name that would exceed a bound is |
| 173 | // refused at create. |
| 174 | // |
| 175 | // Configuration sets stay per account even for a slugged sender: an identity |
| 176 | // has only one default set, and SES has no tenant-level default, so |
| 177 | // per-sender sets on a shared identity are impossible (design §3). |
| 178 | func SenderNames(acct, app, senderID, salt string) (SenderNameSet, error) { |
| 179 | if !release.ValidAccount(acct) { |
| 180 | return SenderNameSet{}, fmt.Errorf("mail: invalid account %q", acct) |
| 181 | } |
| 182 | if !release.ValidApp(app) { |
| 183 | return SenderNameSet{}, fmt.Errorf("mail: invalid app %q", app) |
| 184 | } |
| 185 | if !ValidSenderID(senderID) { |
| 186 | return SenderNameSet{}, fmt.Errorf("mail: invalid sender id %q", senderID) |
| 187 | } |
| 188 | n := SenderNameSet{Tenant: TenantName(acct), ConfigSet: ConfigSetName(acct)} |
| 189 | if senderID == SenderAppID { |
| 190 | n.IAMUser = UserName(acct, app) |
| 191 | return n, checkNames(n) |
| 192 | } |
| 193 | n.Slug = senderSlug(acct, app, senderID, salt) |
| 194 | short := app |
| 195 | if len(short) > senderAppComponentMax { |
| 196 | short = short[:senderAppComponentMax] |
| 197 | } |
| 198 | n.IAMUser = "carlos-mail-" + acct + "-" + short + "+i+" + n.Slug |
| 199 | n.Tenant = "carlos-" + acct + "-" + short + "-" + n.Slug |
| 200 | return n, checkNames(n) |
| 201 | } |
| 202 | |
| 203 | // StandaloneUser is the name of one displayed credential belonging to this |
| 204 | // sender, bound-checked. The rendered string is byte-identical to |
| 205 | // StandaloneUserName for an app sender, because RefreshPolicies recomputes |
| 206 | // the name rather than reading sa.IAMUser (walk.go:364): a rule that |
| 207 | // rendered anything else would write a policy for a principal that does not |
| 208 | // exist and leave the live one stale. |
| 209 | func (n SenderNameSet) StandaloneUser(acct, app, credential string) (string, error) { |
| 210 | if !validStandaloneName(credential) { |
| 211 | return "", fmt.Errorf("mail: invalid credential name %q", credential) |
| 212 | } |
| 213 | name := StandaloneUserName(acct, app, credential) |
| 214 | if n.Slug != "" { |
| 215 | name = n.IAMUser + "+s+" + credential |
| 216 | } |
| 217 | if len(name) > iamUserNameMax { |
| 218 | return "", fmt.Errorf("mail: credential name %q makes the IAM user %d characters, over IAM's %d: choose a shorter name: %w", |
| 219 | credential, len(name), iamUserNameMax, ErrNameTooLong) |
| 220 | } |
| 221 | return name, nil |
| 222 | } |
| 223 | |
| 224 | func checkNames(n SenderNameSet) error { |
| 225 | if len(n.IAMUser) > iamUserNameMax { |
| 226 | return fmt.Errorf("mail: IAM user %q is %d characters, over IAM's %d: %w", n.IAMUser, len(n.IAMUser), iamUserNameMax, ErrNameTooLong) |
| 227 | } |
| 228 | for _, name := range []string{n.Tenant, n.ConfigSet} { |
| 229 | if len(name) > sesNameMax { |
| 230 | return fmt.Errorf("mail: SES name %q is %d characters, over SES's %d: %w", name, len(name), sesNameMax, ErrNameTooLong) |
| 231 | } |
| 232 | } |
| 233 | return nil |
| 234 | } |
| 235 | |
| 236 | // senderSlug is 40 bits of base32 over (account, app, sender, salt), |
| 237 | // lowercased so it reads like every other name this package renders. Nothing |
| 238 | // parses it back, which is why the caller index exists. |
| 239 | func senderSlug(acct, app, id, salt string) string { |
| 240 | sum := sha256.Sum256([]byte(acct + "/" + app + "/" + id + "/" + salt)) |
| 241 | enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:]) |
| 242 | return strings.ToLower(enc[:senderSlugLen]) |
| 243 | } |
| 244 | |
| 245 | // SenderOwnsUserName reports whether user is a name that sender id of |
| 246 | // (acct, app) can own, by FORWARD rendering only: nothing is parsed back out |
| 247 | // of user at a hyphen or at "+s+". It is what the console's backend boundary |
| 248 | // checks a caller index against, so an index can never move a principal out |
| 249 | // of the namespace its own (acct, app, sender) renders. |
| 250 | // |
| 251 | // The app sender owns exactly UserName(acct, app) and every |
| 252 | // StandaloneUserName(acct, app, n) for a valid credential name n. A slugged |
| 253 | // sender owns the names SenderNames renders for SOME salt: the prefix |
| 254 | // carlos-mail-<acct>-<app component>+i+, then an 8 character slug, then |
| 255 | // optionally +s+ and a valid credential name. The salt is not known here (a |
| 256 | // moved salt is written back only after the index), so the slug's shape is |
| 257 | // checked and the create-only index entry is what binds the exact name. |
| 258 | // The forms cannot collide across owners: an account sqid is lowercase |
| 259 | // letters only, and neither an app name nor a credential name can contain |
| 260 | // "+". |
| 261 | func SenderOwnsUserName(acct, app, id, user string) bool { |
| 262 | if id == SenderAppID { |
| 263 | if user == UserName(acct, app) { |
| 264 | return true |
| 265 | } |
| 266 | prefix := StandaloneUserName(acct, app, "") |
| 267 | cred, ok := strings.CutPrefix(user, prefix) |
| 268 | return ok && validStandaloneName(cred) && StandaloneUserName(acct, app, cred) == user |
| 269 | } |
| 270 | n, err := SenderNames(acct, app, id, "") |
| 271 | if err != nil || len(n.IAMUser) <= senderSlugLen { |
| 272 | return false |
| 273 | } |
| 274 | rest, ok := strings.CutPrefix(user, n.IAMUser[:len(n.IAMUser)-senderSlugLen]) |
| 275 | if !ok || len(rest) < senderSlugLen || !validSenderSlug(rest[:senderSlugLen]) { |
| 276 | return false |
| 277 | } |
| 278 | slugged := user[:len(user)-len(rest)+senderSlugLen] |
| 279 | if len(rest) == senderSlugLen { |
| 280 | return len(user) <= iamUserNameMax |
| 281 | } |
| 282 | cred, ok := strings.CutPrefix(rest[senderSlugLen:], "+s+") |
| 283 | return ok && validStandaloneName(cred) && slugged+"+s+"+cred == user && len(user) <= iamUserNameMax |
| 284 | } |
| 285 | |
| 286 | // validSenderSlug is senderSlug's output alphabet and length: lowercased |
| 287 | // unpadded base32. |
| 288 | func validSenderSlug(s string) bool { |
| 289 | if len(s) != senderSlugLen { |
| 290 | return false |
| 291 | } |
| 292 | for _, c := range s { |
| 293 | if !(c >= 'a' && c <= 'z') && !(c >= '2' && c <= '7') { |
| 294 | return false |
| 295 | } |
| 296 | } |
| 297 | return true |
| 298 | } |
| 299 | |
| 300 | // callersPrefix is the reverse index from an IAM caller name to the sender |
| 301 | // that owns it. GLOBAL, not under apps/<acct>/<app>/, because SES hands the |
| 302 | // ingest a bare user name with no account attached and the ingest has |
| 303 | // nowhere to start looking. The price of that placement is that |
| 304 | // release.PurgeApp's prefix sweep never reaches it, so every entry has an |
| 305 | // explicit delete: without one, a stale entry would attribute SES events to |
| 306 | // a purged app and insert counts for it. |
| 307 | const callersPrefix = "email/callers/" |
| 308 | |
| 309 | func CallerKey(iamUser string) string { return callersPrefix + iamUser + ".json" } |
| 310 | |
| 311 | // CallerIndex is the wire shape at CallerKey(IAMUser). |
| 312 | type CallerIndex struct { |
| 313 | Schema int `json:"schema"` // 1 |
| 314 | IAMUser string `json:"iam_user"` |
| 315 | Account string `json:"account"` |
| 316 | App string `json:"app"` |
| 317 | Sender string `json:"sender"` |
| 318 | CreatedAt time.Time `json:"created_at"` |
| 319 | } |
| 320 | |
| 321 | // ErrCallerClaimed means this IAM name already belongs to a different |
| 322 | // sender. For a slugged name that is a hash collision and |
| 323 | // ResolveSenderNames recomputes with a salt; for an unslugged one it is a |
| 324 | // genuine conflict and the caller surfaces it. |
| 325 | var ErrCallerClaimed = errors.New("mail: caller name belongs to another sender") |
| 326 | |
| 327 | // GetCallerIndex reads one entry. Absent is (nil, nil), matching every other |
| 328 | // read in this package. |
| 329 | func GetCallerIndex(ctx context.Context, s release.Store, iamUser string) (*CallerIndex, error) { |
| 330 | raw, _, err := s.Get(ctx, CallerKey(iamUser)) |
| 331 | if err != nil { |
| 332 | if errors.Is(err, os.ErrNotExist) { |
| 333 | return nil, nil |
| 334 | } |
| 335 | return nil, err |
| 336 | } |
| 337 | var c CallerIndex |
| 338 | if err := json.Unmarshal(raw, &c); err != nil { |
| 339 | return nil, fmt.Errorf("mail: caller index %s: %w", iamUser, err) |
| 340 | } |
| 341 | if c.Schema > 1 { |
| 342 | return nil, fmt.Errorf("mail: caller index %s: schema %d is newer than this binary understands, upgrade carlos", iamUser, c.Schema) |
| 343 | } |
| 344 | return &c, nil |
| 345 | } |
| 346 | |
| 347 | // PutCallerIndex claims iamUser for one sender. Create-only, with |
| 348 | // claimDomain's rule (domainclaim.go:119): the same triple is idempotent |
| 349 | // success, any other triple is ErrCallerClaimed. |
| 350 | // |
| 351 | // Written right AFTER EnsureUser, which runs on every enable, so a |
| 352 | // re-enable converges it. Writing it before MintKey would be no better and |
| 353 | // writing it before EnsureUser would be worse: a crash between the index |
| 354 | // write and the user's creation would strand the name against a principal |
| 355 | // that does not exist. |
| 356 | func claimUnserializedPutCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string, now time.Time) error { |
| 357 | body, err := json.Marshal(CallerIndex{Schema: 1, IAMUser: iamUser, Account: acct, App: app, Sender: sender, CreatedAt: now}) |
| 358 | if err != nil { |
| 359 | return err |
| 360 | } |
| 361 | for attempt := 0; ; attempt++ { |
| 362 | cur, err := GetCallerIndex(ctx, s, iamUser) |
| 363 | if err != nil { |
| 364 | return err |
| 365 | } |
| 366 | if cur == nil { |
| 367 | if _, err := s.Put(ctx, CallerKey(iamUser), body, ""); err == nil { |
| 368 | return nil |
| 369 | } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 { |
| 370 | continue // lost the create race, re-read and judge the winner |
| 371 | } else { |
| 372 | return err |
| 373 | } |
| 374 | } |
| 375 | if cur.Account != acct || cur.App != app || cur.Sender != sender { |
| 376 | return ErrCallerClaimed |
| 377 | } |
| 378 | return nil // already ours |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | // DeleteCallerIndex removes an entry, but only when it is held by this |
| 383 | // sender, the same owner gate releaseDomainClaim applies. An entry already |
| 384 | // absent is a quiet no-op, so a retried teardown is not an error. |
| 385 | func claimUnserializedDeleteCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string) error { |
| 386 | cur, err := GetCallerIndex(ctx, s, iamUser) |
| 387 | if err != nil || cur == nil { |
| 388 | return err |
| 389 | } |
| 390 | if cur.Account != acct || cur.App != app || cur.Sender != sender { |
| 391 | return nil |
| 392 | } |
| 393 | return s.Delete(ctx, CallerKey(iamUser)) |
| 394 | } |
| 395 | |
| 396 | // ResolveSenderNames renders a sender's names and claims them in the caller |
| 397 | // index, recomputing a slugged name with a fresh salt on a collision. |
| 398 | // |
| 399 | // It reports whether the salt moved, because a moved salt has to be written |
| 400 | // back to the sender record under its own CAS or the next enable computes a |
| 401 | // different name again and the estate grows an orphan. |
| 402 | func claimUnserializedResolveSenderNames(ctx context.Context, s release.Store, acct, app string, sn *Sender, now time.Time) (SenderNameSet, bool, error) { |
| 403 | salted := false |
| 404 | for attempt := 0; attempt < 4; attempt++ { |
| 405 | names, err := SenderNames(acct, app, sn.ID, sn.Salt) |
| 406 | if err != nil { |
| 407 | return SenderNameSet{}, salted, err |
| 408 | } |
| 409 | err = PutCallerIndex(ctx, s, names.IAMUser, acct, app, sn.ID, now) |
| 410 | if err == nil { |
| 411 | sn.Slug = names.Slug |
| 412 | return names, salted, nil |
| 413 | } |
| 414 | if !errors.Is(err, ErrCallerClaimed) { |
| 415 | return SenderNameSet{}, salted, err |
| 416 | } |
| 417 | if names.Slug == "" { |
| 418 | // An unslugged name is deterministic, so a conflict here is not |
| 419 | // a hash collision: another account or app is genuinely using |
| 420 | // this principal, and a salt would not help. |
| 421 | return SenderNameSet{}, salted, fmt.Errorf("mail: IAM user %s is already claimed by another sender: %w", names.IAMUser, err) |
| 422 | } |
| 423 | buf := make([]byte, 4) |
| 424 | if _, err := rand.Read(buf); err != nil { |
| 425 | return SenderNameSet{}, salted, err |
| 426 | } |
| 427 | sn.Salt = hex.EncodeToString(buf) |
| 428 | salted = true |
| 429 | } |
| 430 | return SenderNameSet{}, salted, fmt.Errorf("mail: could not find a free IAM name for sender %s/%s/%s after four attempts", acct, app, sn.ID) |
| 431 | } |
| 432 | |
| 433 | // SenderOfCaller resolves the IAM identity SES stamps on an event |
| 434 | // (mail.tags["ses:caller-identity"]) to the sender that owns it. |
| 435 | // |
| 436 | // PARSE FIRST, in two steps, and the order is load-bearing. AppOfCaller |
| 437 | // answers for carlos-mail-<acct>-<app> and its +s+ standalones, which is |
| 438 | // every sender that exists today, and it costs nothing. Only a slugged +i+ |
| 439 | // name, which is a hash and unparseable by construction, reaches the index |
| 440 | // Get. Every app enabled today short-circuits Enable before MintKey |
| 441 | // (walk.go:677), so it has no index entry until it next rotates: a Get-only |
| 442 | // resolver would silently stop writing the by_sender log rows that |
| 443 | // sesevents.go writes now, for every app in the fleet, on the roll. |
| 444 | // |
| 445 | // An unreadable index entry is logged and answers "unknown", which puts the |
| 446 | // event back on the domain-wide fallback rather than dropping it. |
| 447 | func SenderOfCaller(ctx context.Context, s release.Store, caller string) (string, string, string, bool) { |
| 448 | if acct, app, ok := AppOfCaller(caller); ok { |
| 449 | return acct, app, SenderAppID, true |
| 450 | } |
| 451 | if !strings.Contains(caller, "+i+") { |
| 452 | return "", "", "", false |
| 453 | } |
| 454 | name := caller |
| 455 | if i := strings.LastIndex(name, "/"); i >= 0 { |
| 456 | name = name[i+1:] |
| 457 | } |
| 458 | idx, err := GetCallerIndex(ctx, s, name) |
| 459 | if err != nil { |
| 460 | log.Printf("mail: caller index %s -> %v", name, err) |
| 461 | return "", "", "", false |
| 462 | } |
| 463 | if idx == nil || !release.ValidAccount(idx.Account) || !release.ValidApp(idx.App) || !ValidSenderID(idx.Sender) { |
| 464 | return "", "", "", false |
| 465 | } |
| 466 | return idx.Account, idx.App, idx.Sender, true |
| 467 | } |
| 468 | |
| 469 | func PutCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string, now time.Time) error { |
| 470 | if _, enabled := s.(ClaimMailStore); !enabled { |
| 471 | return claimUnserializedPutCallerIndex(ctx, s, iamUser, acct, app, sender, now) |
| 472 | } |
| 473 | claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { |
| 474 | return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { |
| 475 | return claimUnserializedPutCallerIndex(ctx, s, iamUser, acct, app, sender, now) |
| 476 | }) |
| 477 | }) |
| 478 | return claimErr |
| 479 | } |
| 480 | |
| 481 | func DeleteCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string) error { |
| 482 | if _, enabled := s.(ClaimMailStore); !enabled { |
| 483 | return claimUnserializedDeleteCallerIndex(ctx, s, iamUser, acct, app, sender) |
| 484 | } |
| 485 | claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { |
| 486 | return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { |
| 487 | return claimUnserializedDeleteCallerIndex(ctx, s, iamUser, acct, app, sender) |
| 488 | }) |
| 489 | }) |
| 490 | return claimErr |
| 491 | } |
| 492 | |
| 493 | func ResolveSenderNames(ctx context.Context, s release.Store, acct, app string, sn *Sender, now time.Time) (SenderNameSet, bool, error) { |
| 494 | if _, enabled := s.(ClaimMailStore); !enabled { |
| 495 | return claimUnserializedResolveSenderNames(ctx, s, acct, app, sn, now) |
| 496 | } |
| 497 | var claimResult0 SenderNameSet |
| 498 | var claimResult1 bool |
| 499 | claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { |
| 500 | return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { |
| 501 | var err error |
| 502 | claimResult0, claimResult1, err = claimUnserializedResolveSenderNames(ctx, s, acct, app, sn, now) |
| 503 | return err |
| 504 | }) |
| 505 | }) |
| 506 | return claimResult0, claimResult1, claimErr |
| 507 | } |
| 508 | |