| 1 | package mail |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // SES's event JSON nests the From address under mail.source and the type |
| 11 | // under eventType. Attribution is by SENDING DOMAIN, because that is the |
| 12 | // only field that ties an event back to a tenant — SES does not know about |
| 13 | // CARLOS accounts. |
| 14 | func TestParseEventAttributesByDomain(t *testing.T) { |
| 15 | body := []byte(`{ |
| 16 | "eventType": "Bounce", |
| 17 | "mail": { |
| 18 | "timestamp": "2026-08-19T12:00:00.000Z", |
| 19 | "messageId": "0100abc", |
| 20 | "source": "noreply@harbourmail.bag.oncarlos.com" |
| 21 | } |
| 22 | }`) |
| 23 | ev, err := ParseEvent(body) |
| 24 | if err != nil { |
| 25 | t.Fatalf("ParseEvent: %v", err) |
| 26 | } |
| 27 | if ev.Type != EventBounce { |
| 28 | t.Fatalf("Type = %q, want %q", ev.Type, EventBounce) |
| 29 | } |
| 30 | if ev.Domain != "harbourmail.bag.oncarlos.com" { |
| 31 | t.Fatalf("Domain = %q", ev.Domain) |
| 32 | } |
| 33 | if ev.MessageID != "0100abc" { |
| 34 | t.Fatalf("MessageID = %q", ev.MessageID) |
| 35 | } |
| 36 | if ev.At.IsZero() { |
| 37 | t.Fatal("timestamp did not parse") |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | // An event type this build does not know must not fail the whole delivery — |
| 42 | // SES adds event types, and a 500 makes SNS retry forever. |
| 43 | func TestParseEventAcceptsAnUnknownType(t *testing.T) { |
| 44 | ev, err := ParseEvent([]byte(`{"eventType":"Subscription","mail":{"source":"a@b.com"}}`)) |
| 45 | if err != nil { |
| 46 | t.Fatalf("an unknown event type must parse, got %v", err) |
| 47 | } |
| 48 | if ev.Type != "Subscription" { |
| 49 | t.Fatalf("Type = %q", ev.Type) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func TestParseEventRefusesGarbage(t *testing.T) { |
| 54 | if _, err := ParseEvent([]byte(`not json`)); err == nil { |
| 55 | t.Fatal("garbage parsed") |
| 56 | } |
| 57 | if _, err := ParseEvent([]byte(`{"eventType":"Send"}`)); err == nil { |
| 58 | t.Fatal("an event with no source parsed — it can be attributed to nobody") |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | func TestDomainOfIsCaseFoldedAndAngleTolerant(t *testing.T) { |
| 63 | for _, c := range []struct{ in, want string }{ |
| 64 | {"noreply@Example.COM", "example.com"}, |
| 65 | {"Bob <bob@example.com>", "example.com"}, |
| 66 | {"nodomain", ""}, |
| 67 | } { |
| 68 | if got := DomainOf(c.in); got != c.want { |
| 69 | t.Fatalf("DomainOf(%q) = %q, want %q", c.in, got, c.want) |
| 70 | } |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | // Stray trailing > from unbalanced brackets should be stripped. |
| 75 | func TestDomainOfStripsStrayTrailingBracket(t *testing.T) { |
| 76 | for _, c := range []struct{ in, want string }{ |
| 77 | {"a@b.com>", "b.com"}, |
| 78 | {"<<a@b.com>>", "b.com"}, |
| 79 | } { |
| 80 | if got := DomainOf(c.in); got != c.want { |
| 81 | t.Fatalf("DomainOf(%q) = %q, want %q", c.in, got, c.want) |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // Trailing FQDN dots should be normalised away. |
| 87 | func TestDomainOfStripsTrailingDot(t *testing.T) { |
| 88 | if got := DomainOf("a@b.com."); got != "b.com" { |
| 89 | t.Fatalf("DomainOf(\"a@b.com.\") = %q, want \"b.com\"", got) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // Multi-address sources are ambiguous and must be refused. Attribution is by |
| 94 | // domain, and multiple addresses would make it impossible to know which tenant |
| 95 | // the event belongs to. |
| 96 | func TestParseEventRefusesAmbiguousMultiAddressSources(t *testing.T) { |
| 97 | testCases := []string{ |
| 98 | // Comma inside raw source (not in display name) indicates multiple addresses |
| 99 | `{"eventType":"Send","mail":{"source":"a@b.com,evil@attacker.com"}}`, |
| 100 | // Multiple bracket pairs separated by comma outside brackets |
| 101 | `{"eventType":"Send","mail":{"source":"Legit <legit@good.com>, Evil <evil@bad.com>"}}`, |
| 102 | // Multiple bracket pairs with no space between them |
| 103 | `{"eventType":"Send","mail":{"source":"<a@good.com><b@evil.com>"}}`, |
| 104 | } |
| 105 | for _, tc := range testCases { |
| 106 | if _, err := ParseEvent([]byte(tc)); err == nil { |
| 107 | t.Fatalf("ParseEvent accepted ambiguous multi-address source: %s", tc) |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // Display-name-with-comma is standard and must be accepted. A comma inside |
| 113 | // a display name (like "Smith, Bob") does not indicate multiple addresses. |
| 114 | func TestParseEventAcceptsDisplayNameWithComma(t *testing.T) { |
| 115 | testCases := []struct { |
| 116 | source string |
| 117 | want string |
| 118 | }{ |
| 119 | {`Smith, Bob <bob@example.com>`, `example.com`}, |
| 120 | {`Bob <bob@example.com>`, `example.com`}, |
| 121 | {`noreply+tag@example.com`, `example.com`}, |
| 122 | {`noreply@mail-eu-west-1.harbourmail.bag.oncarlos.com`, `mail-eu-west-1.harbourmail.bag.oncarlos.com`}, |
| 123 | } |
| 124 | for _, tc := range testCases { |
| 125 | ev, err := ParseEvent([]byte(`{"eventType":"Send","mail":{"source":"` + tc.source + `"}}`)) |
| 126 | if err != nil { |
| 127 | t.Fatalf("ParseEvent rejected valid address %q: %v", tc.source, err) |
| 128 | } |
| 129 | if ev.Domain != tc.want { |
| 130 | t.Fatalf("Domain for %q = %q, want %q", tc.source, ev.Domain, tc.want) |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // Control characters in the source indicate malformation or injection. |
| 136 | func TestParseEventRefusesControlCharacters(t *testing.T) { |
| 137 | testCases := []struct { |
| 138 | name string |
| 139 | body string |
| 140 | }{ |
| 141 | {"newline", `{"eventType":"Send","mail":{"source":"a@b.com\nX-Injected: true"}}`}, |
| 142 | {"null", `{"eventType":"Send","mail":{"source":"a@b.com\x00injected"}}`}, |
| 143 | } |
| 144 | for _, tc := range testCases { |
| 145 | if _, err := ParseEvent([]byte(tc.body)); err == nil { |
| 146 | t.Fatalf("ParseEvent accepted control character in source (%s)", tc.name) |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // A malformed timestamp must be silently ignored, not fail the parse. Timestamp |
| 152 | // is unrelated to attribution, so failing loudly would block valid events. |
| 153 | func TestParseEventIgnoresMalformedTimestamp(t *testing.T) { |
| 154 | ev, err := ParseEvent([]byte(`{ |
| 155 | "eventType":"Send", |
| 156 | "mail":{ |
| 157 | "timestamp":"not-a-time", |
| 158 | "messageId":"msg123", |
| 159 | "source":"a@b.com" |
| 160 | } |
| 161 | }`)) |
| 162 | if err != nil { |
| 163 | t.Fatalf("ParseEvent failed on malformed timestamp: %v", err) |
| 164 | } |
| 165 | if !ev.At.IsZero() { |
| 166 | t.Fatalf("At = %v, want zero (timestamp ignored)", ev.At) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // What SES publishes for a bounce, in the shape its event destinations |
| 171 | // send: the message's own envelope under mail, the automatic tags naming |
| 172 | // the sending identity, and a bounce block that says, per recipient, what |
| 173 | // the receiving server answered. |
| 174 | const bounceEvent = `{ |
| 175 | "eventType": "Bounce", |
| 176 | "bounce": { |
| 177 | "bounceType": "Permanent", |
| 178 | "bounceSubType": "NoEmail", |
| 179 | "bouncedRecipients": [ |
| 180 | {"emailAddress": "ann@example.com", "action": "failed", "status": "5.1.1", |
| 181 | "diagnosticCode": "smtp; 550 5.1.1 <ann@example.com>: Recipient address rejected"} |
| 182 | ], |
| 183 | "timestamp": "2026-09-15T08:00:02.000Z", |
| 184 | "feedbackId": "0102019-feedback" |
| 185 | }, |
| 186 | "mail": { |
| 187 | "timestamp": "2026-09-15T08:00:00.000Z", |
| 188 | "source": "bookings@lgc.li", |
| 189 | "messageId": "0102019abc-7c8d-000000", |
| 190 | "destination": ["ann@example.com"], |
| 191 | "tags": { |
| 192 | "ses:caller-identity": ["carlos-mail-beb-jb-book"], |
| 193 | "ses:from-domain": ["lgc.li"] |
| 194 | } |
| 195 | } |
| 196 | }` |
| 197 | |
| 198 | func TestParseEventReadsWhoBouncedAndWhy(t *testing.T) { |
| 199 | ev, err := ParseEvent([]byte(bounceEvent)) |
| 200 | if err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | if ev.MessageID != "0102019abc-7c8d-000000" || ev.Caller != "carlos-mail-beb-jb-book" { |
| 204 | t.Errorf("message %q, caller %q", ev.MessageID, ev.Caller) |
| 205 | } |
| 206 | if len(ev.Outcomes) != 1 { |
| 207 | t.Fatalf("outcomes = %+v, want one", ev.Outcomes) |
| 208 | } |
| 209 | o := ev.Outcomes[0] |
| 210 | if o.Recipient != "ann@example.com" || o.Status != "5.1.1" || !strings.Contains(o.Diagnostic, "Recipient address rejected") { |
| 211 | t.Errorf("outcome = %+v", o) |
| 212 | } |
| 213 | if got := ev.Detail.Summary(); got != "Permanent/NoEmail" { |
| 214 | t.Errorf("summary = %q", got) |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | func TestParseEventReadsEachTypesOwnRecipients(t *testing.T) { |
| 219 | for _, c := range []struct { |
| 220 | name, body, recipient, diagnostic, summary string |
| 221 | }{ |
| 222 | {"delivery", `{"eventType":"Delivery","mail":{"source":"a@lgc.li","messageId":"m1","destination":["bob@example.com"]}, |
| 223 | "delivery":{"recipients":["bob@example.com"],"smtpResponse":"250 2.0.0 OK 1726387202"}}`, |
| 224 | "bob@example.com", "250 2.0.0 OK 1726387202", ""}, |
| 225 | {"complaint", `{"eventType":"Complaint","mail":{"source":"a@lgc.li","messageId":"m2"}, |
| 226 | "complaint":{"complaintFeedbackType":"abuse","complainedRecipients":[{"emailAddress":"cat@example.com"}]}}`, |
| 227 | "cat@example.com", "", "abuse"}, |
| 228 | {"delay", `{"eventType":"DeliveryDelay","mail":{"source":"a@lgc.li","messageId":"m3"}, |
| 229 | "deliveryDelay":{"delayType":"MailboxFull","delayedRecipients":[{"emailAddress":"dan@example.com","status":"4.2.2","diagnosticCode":"452 mailbox full"}]}}`, |
| 230 | "dan@example.com", "452 mailbox full", "MailboxFull"}, |
| 231 | } { |
| 232 | t.Run(c.name, func(t *testing.T) { |
| 233 | ev, err := ParseEvent([]byte(c.body)) |
| 234 | if err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | rs := ev.Recipients() |
| 238 | if len(rs) != 1 || rs[0].Recipient != c.recipient || rs[0].Diagnostic != c.diagnostic { |
| 239 | t.Errorf("recipients = %+v", rs) |
| 240 | } |
| 241 | if got := ev.Detail.Summary(); got != c.summary { |
| 242 | t.Errorf("summary = %q, want %q", got, c.summary) |
| 243 | } |
| 244 | }) |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | // A Send or a Reject is about the whole message, so it concerns everyone |
| 249 | // the message was addressed to. An event that names nobody is still one |
| 250 | // event, not none. |
| 251 | func TestAnEventAboutTheWholeMessageConcernsEveryAddressee(t *testing.T) { |
| 252 | ev, err := ParseEvent([]byte(`{"eventType":"Reject","mail":{"source":"a@lgc.li","messageId":"m4", |
| 253 | "destination":["one@example.com","two@example.com"]},"reject":{"reason":"Bad content"}}`)) |
| 254 | if err != nil { |
| 255 | t.Fatal(err) |
| 256 | } |
| 257 | if rs := ev.Recipients(); len(rs) != 2 || rs[1].Recipient != "two@example.com" { |
| 258 | t.Errorf("recipients = %+v", rs) |
| 259 | } |
| 260 | if ev.Detail.Summary() != "Bad content" { |
| 261 | t.Errorf("summary = %q", ev.Detail.Summary()) |
| 262 | } |
| 263 | bare, _ := ParseEvent([]byte(`{"eventType":"Send","mail":{"source":"a@lgc.li"}}`)) |
| 264 | if rs := bare.Recipients(); len(rs) != 1 || rs[0].Recipient != "" { |
| 265 | t.Errorf("an event naming nobody should still be one entry: %+v", rs) |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | // A type's recipients are read from its own block only. A malformed |
| 270 | // message carrying a delivery block under a bounce must not report the |
| 271 | // delivered address as bounced. |
| 272 | func TestParseEventReadsOnlyTheBlockItsTypeNames(t *testing.T) { |
| 273 | ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li"}, |
| 274 | "delivery":{"recipients":["bob@example.com"]}}`)) |
| 275 | if err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | if len(ev.Outcomes) != 0 { |
| 279 | t.Errorf("a bounce reported the delivery block's recipients: %+v", ev.Outcomes) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | // The text in an event was written by whichever server answered. It is |
| 284 | // kept, so it is kept clean: no control characters, and bounded. |
| 285 | func TestEventTextIsCleanedBeforeItIsKept(t *testing.T) { |
| 286 | long := strings.Repeat("x", 5000) |
| 287 | ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li"},"bounce":{"bounceType":"Permanent", |
| 288 | "bouncedRecipients":[{"emailAddress":"ann@example.com\r\nX-Injected: yes","diagnosticCode":"` + long + `"}]}}`)) |
| 289 | if err != nil { |
| 290 | t.Fatal(err) |
| 291 | } |
| 292 | o := ev.Outcomes[0] |
| 293 | if strings.ContainsAny(o.Recipient, "\r\n") { |
| 294 | t.Errorf("control characters survived: %q", o.Recipient) |
| 295 | } |
| 296 | if len(o.Diagnostic) > maxDiagnosticLen { |
| 297 | t.Errorf("diagnostic kept at %d bytes", len(o.Diagnostic)) |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | func TestAppOfCallerReadsBackTheDeliveredIdentity(t *testing.T) { |
| 302 | for _, c := range []struct { |
| 303 | caller, acct, app string |
| 304 | ok bool |
| 305 | }{ |
| 306 | {UserName("beb", "jb-book"), "beb", "jb-book", true}, |
| 307 | {StandaloneUserName("beb", "jb-book", "laptop"), "beb", "jb-book", true}, |
| 308 | {"arn:aws:iam::123456789012:user/carlos/mail/carlos-mail-beb-jb-book", "beb", "jb-book", true}, |
| 309 | {"arn:aws:sts::123456789012:assumed-role/x/carlos-mail-beb-jb-book", "", "", false}, // a role session names itself |
| 310 | {"arn:aws:iam::123456789012:role/carlos-mail-beb-jb-book", "", "", false}, // not a user |
| 311 | {"carlos/mail/carlos-mail-beb-jb-book", "", "", false}, // a path with no ARN |
| 312 | {"carlos-mail-beb-jb-book+s+", "", "", false}, // a standalone with no name |
| 313 | {"carlos-mail-beb", "", "", false}, // no app |
| 314 | {"carlos-mail-BEB-jb-book", "", "", false}, // not an account sqid |
| 315 | {"someone-else-entirely", "", "", false}, // not ours |
| 316 | {"", "", "", false}, |
| 317 | } { |
| 318 | acct, app, ok := AppOfCaller(c.caller) |
| 319 | if acct != c.acct || app != c.app || ok != c.ok { |
| 320 | t.Errorf("AppOfCaller(%q) = %q, %q, %v; want %q, %q, %v", c.caller, acct, app, ok, c.acct, c.app, c.ok) |
| 321 | } |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | // A terminal can be made to show something the bytes do not say: an 8-bit |
| 326 | // CSI, or a right-to-left override that reverses the rest of the line. The |
| 327 | // far end writes the diagnostic, so none of that is kept. |
| 328 | func TestEventTextLosesInvisibleAndEightBitControls(t *testing.T) { |
| 329 | sneaky := []rune{0x9b, 0x202e, 0x202c, 0x200b} |
| 330 | diag := "550 " + string(sneaky[0]) + "31m red " + string(sneaky[1]) + "evil" + string(sneaky[2]) + string(sneaky[3]) + " ok" |
| 331 | quoted, _ := json.Marshal(diag) |
| 332 | ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li"},"bounce":{"bouncedRecipients":[ |
| 333 | {"emailAddress":"ann@example.com","diagnosticCode":` + string(quoted) + `}]}}`)) |
| 334 | if err != nil { |
| 335 | t.Fatal(err) |
| 336 | } |
| 337 | for _, r := range ev.Outcomes[0].Diagnostic { |
| 338 | for _, bad := range sneaky { |
| 339 | if r == bad { |
| 340 | t.Errorf("%U survived in %q", r, ev.Outcomes[0].Diagnostic) |
| 341 | } |
| 342 | } |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | // A bounce can come a day after the send. The event is dated by the bounce, |
| 347 | // and the send is kept beside it. |
| 348 | func TestAnEventIsDatedByWhatItReports(t *testing.T) { |
| 349 | ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li","timestamp":"2026-09-14T08:00:00Z"}, |
| 350 | "bounce":{"timestamp":"2026-09-15T09:30:00Z","bouncedRecipients":[{"emailAddress":"ann@example.com"}]}}`)) |
| 351 | if err != nil { |
| 352 | t.Fatal(err) |
| 353 | } |
| 354 | if got := ev.At.UTC().Format(time.RFC3339); got != "2026-09-15T09:30:00Z" { |
| 355 | t.Errorf("At = %s, want the bounce's own time", got) |
| 356 | } |
| 357 | if got := ev.SentAt.UTC().Format(time.RFC3339); got != "2026-09-14T08:00:00Z" { |
| 358 | t.Errorf("SentAt = %s, want the send", got) |
| 359 | } |
| 360 | send, _ := ParseEvent([]byte(`{"eventType":"Send","mail":{"source":"a@lgc.li","timestamp":"2026-09-14T08:00:00Z"}}`)) |
| 361 | if !send.At.Equal(send.SentAt) || send.At.IsZero() { |
| 362 | t.Errorf("a Send is dated by the send: %v / %v", send.At, send.SentAt) |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | // What only the log reads must never cost the count. A notification whose |
| 367 | // tags, destination or type block arrive in a shape this code does not |
| 368 | // expect still parses for everything the counts and the cap depend on, and |
| 369 | // says what it could not read. |
| 370 | func TestAMisshapedLogFieldDoesNotCostTheEvent(t *testing.T) { |
| 371 | for name, body := range map[string]string{ |
| 372 | "tags as a string": `{"eventType":"Send","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z","tags":{"ses:caller-identity":"x"}}}`, |
| 373 | "destination as a string": `{"eventType":"Send","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z","destination":"ann@example.com"}}`, |
| 374 | "recipients as objects": `{"eventType":"Delivery","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z"},"delivery":{"recipients":[{"a":1}]}}`, |
| 375 | "reason as an object": `{"eventType":"Reject","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z"},"reject":{"reason":{"x":1}}}`, |
| 376 | "bounce type as a number": `{"eventType":"Bounce","mail":{"source":"a@lgc.li","timestamp":"2026-09-15T08:00:00.000Z","tags":{"ses:caller-identity":["carlos-mail-beb-jb-book"]}},"bounce":{"bounceType":7}}`, |
| 377 | } { |
| 378 | t.Run(name, func(t *testing.T) { |
| 379 | ev, err := ParseEvent([]byte(body)) |
| 380 | if err != nil { |
| 381 | t.Fatalf("refused — a count would be dropped for a field only the log reads: %v", err) |
| 382 | } |
| 383 | if ev.Type == "" || ev.Domain != "lgc.li" || ev.SentAt.IsZero() { |
| 384 | t.Errorf("the fields the counts need did not survive: %+v", ev) |
| 385 | } |
| 386 | if ev.Incomplete == "" { |
| 387 | t.Error("what could not be read should be said, for the log") |
| 388 | } |
| 389 | }) |
| 390 | } |
| 391 | // A malformed type block does not also cost the envelope beside it. |
| 392 | ev, _ := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li","tags":{"ses:caller-identity":["carlos-mail-beb-jb-book"]}},"bounce":{"bounceType":7}}`)) |
| 393 | if ev.Caller != "carlos-mail-beb-jb-book" { |
| 394 | t.Errorf("a malformed bounce block cost the sender: %q", ev.Caller) |
| 395 | } |
| 396 | good, _ := ParseEvent([]byte(bounceEvent)) |
| 397 | if good.Incomplete != "" { |
| 398 | t.Errorf("a well-formed event claims to be incomplete: %q", good.Incomplete) |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | // A bounce is about the recipients it names. One that names nobody is not a |
| 403 | // bounce for everybody the message was addressed to. |
| 404 | func TestAPerRecipientEventThatNamesNobodyIsNotWidened(t *testing.T) { |
| 405 | ev, err := ParseEvent([]byte(`{"eventType":"Bounce","mail":{"source":"a@lgc.li","messageId":"m", |
| 406 | "destination":["one@example.com","two@example.com","three@example.com"]},"bounce":{"bounceType":"Permanent","bouncedRecipients":[]}}`)) |
| 407 | if err != nil { |
| 408 | t.Fatal(err) |
| 409 | } |
| 410 | if rs := ev.Recipients(); len(rs) != 1 || rs[0].Recipient != "" { |
| 411 | t.Errorf("a bounce naming nobody was widened to %+v", rs) |
| 412 | } |
| 413 | } |
| 414 | |