Skip to content

Commit 58022f2

Browse files
authored
fix: send resends and releases to the right recipients (#33)
1 parent 7c58dd2 commit 58022f2

8 files changed

Lines changed: 99 additions & 22 deletions

File tree

‎readme.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -909,9 +909,11 @@ Postmaster::resend($message);
909909
Postmaster::resend($messageId);
910910
```
911911

912-
The new send carries over everything we can reconstruct from the recorded
913-
row — sender, To/Cc/Bcc envelope, subject, html and text bodies, related /
914-
recipient / tenant context, tags — and gets a `resent` tag of its own. The
912+
The new send goes only to the recipient on that row, in To. Other recipients of
913+
the original are not sent it again, so resend a Cc or Bcc row to reach that
914+
person alone. It carries over everything else we can reconstruct from the
915+
recorded row — sender, subject, html and text bodies, related / recipient /
916+
tenant context, tags — and gets a `resent` tag of its own. The
915917
new row's `resent_from_id` points back to the original so the chain is
916918
queryable:
917919

‎resources/views/message.blade.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
@if ($canRelease)
5555
<x-postmaster::confirm-action
5656
:action="route('postmaster.messages.release', $message)"
57-
:confirm="'Release this sandboxed email and send it for real to '.$message->to_address.'? This cannot be undone.'"
57+
:confirm="'Release this sandboxed email and send it for real to '.implode(', ', $message->envelopeAddresses()).'? This cannot be undone.'"
5858
:title="$incomplete"
5959
label="Release"/>
6060
@endif

‎src/Mail/ReleasedMessage.php‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,19 @@ public function build(): static
3737
$this->from($this->record->from_address);
3838
}
3939

40-
$this->to($this->record->to_address);
41-
40+
// Release marks every sibling row sent, so it sends the original
41+
// envelope, whichever row the operator released from.
4242
$recipients = $this->record->recipients ?? [];
4343

44+
foreach ($recipients['to'] ?? [] as $to) {
45+
$this->to($to['address']);
46+
}
47+
48+
// A row with no recorded envelope can only name its own recipient.
49+
if ($recipients === []) {
50+
$this->to($this->record->to_address);
51+
}
52+
4453
foreach ($recipients['cc'] ?? [] as $cc) {
4554
$this->cc($cc['address']);
4655
}

‎src/Mail/ResentMessage.php‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,11 @@ public function build(): static
3434
$this->from($this->record->from_address);
3535
}
3636

37+
// A resend starts from one recipient's row, so it goes to that
38+
// recipient alone. Copying the original Cc/Bcc would send it again
39+
// to people nobody asked to resend to, past their suppression checks.
3740
$this->to($this->record->to_address);
3841

39-
$recipients = $this->record->recipients ?? [];
40-
41-
foreach ($recipients['cc'] ?? [] as $cc) {
42-
$this->cc($cc['address']);
43-
}
44-
45-
foreach ($recipients['bcc'] ?? [] as $bcc) {
46-
$this->bcc($bcc['address']);
47-
}
48-
4942
if ($this->record->subject) {
5043
$this->subject($this->record->subject);
5144
}

‎src/Models/EmailMessage.php‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,23 @@ public function hasStoredContent(): bool
157157
return (bool) ($this->html_body ?: $this->text_body);
158158
}
159159

160+
/**
161+
* Every To, Cc, and Bcc address of the original send, in that order.
162+
* Falls back to this row's own address when no envelope was stored.
163+
*
164+
* @return list<string>
165+
*/
166+
public function envelopeAddresses(): array
167+
{
168+
$recipients = $this->recipients ?? [];
169+
170+
if ($recipients === []) {
171+
return array_filter([$this->to_address]);
172+
}
173+
174+
return array_column([...$recipients['to'] ?? [], ...$recipients['cc'] ?? [], ...$recipients['bcc'] ?? []], 'address');
175+
}
176+
160177
/**
161178
* Whether this message's recipient is on the local suppression list.
162179
* False when there's no recipient recorded at all — callers asking this
@@ -450,9 +467,9 @@ public function resends(): HasMany
450467
}
451468

452469
/**
453-
* Replay this message through the configured mailer, preserving its
454-
* sender, recipients, subject, bodies, and tracking context (plus a
455-
* `resent` tag of its own). The new row links back to this one via
470+
* Replay this message to this row's recipient alone, preserving its
471+
* sender, subject, bodies, and tracking context (plus a `resent` tag
472+
* of its own). The new row links back to this one via
456473
* resent_from_id. Requires stored content; attachments come along when
457474
* their bytes are still stored.
458475
*

‎src/Postmaster.php‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -456,9 +456,10 @@ public function resentFrom(EmailMessage|int $message): Closure
456456

457457
/**
458458
* Replay a previously recorded EmailMessage through the configured
459-
* mailer, preserving every aspect of the original we can reconstruct:
460-
* sender, To/Cc/Bcc envelope, subject, html and text bodies, related /
461-
* recipient / tenant context, and tags (plus a `resent` tag of its own).
459+
* mailer to that row's recipient alone, preserving every other aspect
460+
* of the original we can reconstruct: sender, subject, html and text
461+
* bodies, related / recipient / tenant context, and tags (plus a
462+
* `resent` tag of its own). Other Cc/Bcc recipients are not re-sent.
462463
* The new send is linked back to the original via resent_from_id, which
463464
* the dashboard's chain card walks to show the full retry history.
464465
*

‎tests/ResendTest.php‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,30 @@ public function testResendChainCalledOnTheRootStillReturnsAllDescendants()
131131
$this->assertSame($original->id, $chain[0]->id);
132132
}
133133

134+
/**
135+
* The dashboard asks "Resend this email to {address}?" on one
136+
* recipient's row, so only that recipient should get it again.
137+
*/
138+
public function testResendGoesOnlyToTheRecipientWhoseRowWasResent()
139+
{
140+
Mail::html('<p>Hello all</p>', function ($m) {
141+
$m->to('to@example.com')->cc('cc@example.com')->bcc('bcc@example.com')->subject('Team update');
142+
});
143+
144+
foreach (['to', 'cc', 'bcc'] as $role) {
145+
Postmaster::resend(EmailMessage::where('recipient_role', $role)->whereNull('resent_from_id')->firstOrFail());
146+
147+
$sent = Mail::mailer('array')->getSymfonyTransport()->messages()->last()->getOriginalMessage();
148+
$this->assertSame(["$role@example.com"], array_map(fn ($a) => $a->getAddress(), $sent->getTo()), $role);
149+
$this->assertSame([], $sent->getCc(), $role);
150+
$this->assertSame([], $sent->getBcc(), $role);
151+
}
152+
153+
$resends = EmailMessage::whereNotNull('resent_from_id')->get();
154+
$this->assertSame(['to@example.com', 'cc@example.com', 'bcc@example.com'], $resends->pluck('to_address')->all());
155+
$this->assertSame(['to', 'to', 'to'], $resends->pluck('recipient_role')->all());
156+
}
157+
134158
public function testTrackingResentFromIsRespected()
135159
{
136160
Mail::raw('first send', function ($m) {

‎tests/SandboxReleaseTest.php‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,37 @@ public function testReleaseTransitionsEveryEnvelopeSibling()
206206
$this->assertStringStartsNotWith('sandboxed-', (string) $fresh->first()->provider_message_id);
207207
}
208208

209+
/**
210+
* Release flips every sibling row to sent, so the send must reach the
211+
* whole original envelope, whichever row the operator released from.
212+
*/
213+
public function testReleaseFromAnyRowSendsTheOriginalEnvelope()
214+
{
215+
Mail::html('<p>Hello all</p>', function ($m) {
216+
$m->to(['to@example.com', 'to2@example.com'])->cc('cc@example.com')->bcc('bcc@example.com')->subject('Team update');
217+
});
218+
219+
Postmaster::release(EmailMessage::where('recipient_role', 'bcc')->firstOrFail());
220+
221+
$sent = Mail::mailer('array')->getSymfonyTransport()->messages()->last()->getOriginalMessage();
222+
$addresses = fn (array $list) => array_map(fn ($a) => $a->getAddress(), $list);
223+
$this->assertSame(['to@example.com', 'to2@example.com'], $addresses($sent->getTo()));
224+
$this->assertSame(['cc@example.com'], $addresses($sent->getCc()));
225+
$this->assertSame(['bcc@example.com'], $addresses($sent->getBcc()));
226+
}
227+
228+
public function testDashboardReleaseConfirmationNamesEveryRecipient()
229+
{
230+
Postmaster::auth(fn () => true);
231+
Mail::html('<p>Hello all</p>', function ($m) {
232+
$m->to('to@example.com')->cc('cc@example.com')->subject('Team update');
233+
});
234+
235+
$this->get('/postmaster/messages/'.EmailMessage::where('recipient_role', 'cc')->firstOrFail()->getKey())
236+
->assertOk()
237+
->assertSee('send it for real to to@example.com, cc@example.com?');
238+
}
239+
209240
public function testDashboardShowsReleaseOnASandboxedMessage()
210241
{
211242
Postmaster::auth(fn () => true);

0 commit comments

Comments
 (0)