Add recipient email and milestone notifications on transfer create.
Deploy Ladill Transfer / deploy (push) Successful in 44s

Recipients can be emailed the download link immediately and at optional
reminders (7/3/1 days before unavailable, or when grace period starts).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-08 12:38:58 +00:00
co-authored by Cursor
parent 311b5b40bb
commit 7c4673adb6
15 changed files with 851 additions and 5 deletions
@@ -3,6 +3,7 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Services\Transfer\TransferBillingService; use App\Services\Transfer\TransferBillingService;
use App\Services\Transfer\TransferRecipientMailService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class ProcessTransferBillingCommand extends Command class ProcessTransferBillingCommand extends Command
@@ -11,15 +12,17 @@ class ProcessTransferBillingCommand extends Command
protected $description = 'Renew monthly transfer storage billing and delete transfers past the grace period'; protected $description = 'Renew monthly transfer storage billing and delete transfers past the grace period';
public function handle(TransferBillingService $billing): int public function handle(TransferBillingService $billing, TransferRecipientMailService $recipients): int
{ {
$result = $billing->processDueRenewals(); $result = $billing->processDueRenewals();
$recipientEmails = $recipients->processDueMilestones();
$this->info(sprintf( $this->info(sprintf(
'Transfer billing: %d renewed, %d entered grace, %d deleted.', 'Transfer billing: %d renewed, %d entered grace, %d deleted, %d recipient reminders sent.',
$result['renewed'], $result['renewed'],
$result['graced'], $result['graced'],
$result['deleted'], $result['deleted'],
$recipientEmails,
)); ));
return self::SUCCESS; return self::SUCCESS;
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Transfer; use App\Models\Transfer;
use App\Services\Qr\QrImageGeneratorService; use App\Services\Qr\QrImageGeneratorService;
use App\Services\Qr\QrPdfExporter; use App\Services\Qr\QrPdfExporter;
use App\Services\Transfer\TransferRecipientMailService;
use App\Services\Transfer\TransferService; use App\Services\Transfer\TransferService;
use App\Services\Upload\ChunkedUploadService; use App\Services\Upload\ChunkedUploadService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -41,10 +42,14 @@ class TransferController extends Controller
public function create(): View public function create(): View
{ {
$mailService = app(TransferRecipientMailService::class);
return view('transfer.transfers.create', [ return view('transfer.transfers.create', [
'maxFiles' => (int) config('transfer.max_files_per_transfer', 20), 'maxFiles' => (int) config('transfer.max_files_per_transfer', 20),
'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30), 'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30),
'gracePeriodDays' => (int) config('transfer.grace_period_days', 15), 'gracePeriodDays' => (int) config('transfer.grace_period_days', 15),
'emailMilestones' => $mailService->milestoneOptions(),
'defaultEmailMilestones' => (array) config('transfer.default_recipient_email_milestones', ['created']),
]); ]);
} }
@@ -58,9 +63,14 @@ class TransferController extends Controller
$fileRules[] = 'max:'.(int) ceil($maxBytes / 1024); $fileRules[] = 'max:'.(int) ceil($maxBytes / 1024);
} }
$allowedMilestones = implode(',', app(TransferRecipientMailService::class)->allowedMilestones());
$data = $request->validate([ $data = $request->validate([
'title' => 'required|string|max:120', 'title' => 'required|string|max:120',
'message' => 'nullable|string|max:2000', 'message' => 'nullable|string|max:2000',
'recipient_email' => 'nullable|email|max:255|required_with:email_milestones',
'email_milestones' => 'nullable|array',
'email_milestones.*' => 'in:'.$allowedMilestones,
'password' => 'nullable|string|min:4|max:64', 'password' => 'nullable|string|min:4|max:64',
'files' => 'nullable|array', 'files' => 'nullable|array',
'files.*' => $fileRules, 'files.*' => $fileRules,
@@ -94,9 +104,14 @@ class TransferController extends Controller
} }
} }
$success = 'Transfer created. Share the link or QR code with recipients.';
if ($transfer->recipient_email && $transfer->wantsRecipientMilestone('created') && $transfer->recipientMilestoneSent('created')) {
$success .= ' A download link was emailed to '.$transfer->recipient_email.'.';
}
return redirect() return redirect()
->route('transfer.transfers.show', $transfer) ->route('transfer.transfers.show', $transfer)
->with('success', 'Transfer created. Share the link or QR code with recipients.'); ->with('success', $success);
} }
public function show(Transfer $transfer): View public function show(Transfer $transfer): View
+25
View File
@@ -23,6 +23,9 @@ class Transfer extends Model
'qr_code_id', 'qr_code_id',
'title', 'title',
'message', 'message',
'recipient_email',
'recipient_email_milestones',
'recipient_milestones_sent',
'password_hash', 'password_hash',
'retention_days', 'retention_days',
'expires_at', 'expires_at',
@@ -40,6 +43,8 @@ class Transfer extends Model
'paid_until' => 'datetime', 'paid_until' => 'datetime',
'grace_ends_at' => 'datetime', 'grace_ends_at' => 'datetime',
'last_billed_at' => 'datetime', 'last_billed_at' => 'datetime',
'recipient_email_milestones' => 'array',
'recipient_milestones_sent' => 'array',
'downloads_total' => 'integer', 'downloads_total' => 'integer',
'storage_bytes' => 'integer', 'storage_bytes' => 'integer',
]; ];
@@ -149,4 +154,24 @@ class Transfer extends Model
->orWhere('grace_ends_at', '>', now()); ->orWhere('grace_ends_at', '>', now());
}); });
} }
public function wantsRecipientMilestone(string $milestone): bool
{
return in_array($milestone, (array) ($this->recipient_email_milestones ?? []), true);
}
public function recipientMilestoneSent(string $milestone): bool
{
return in_array($milestone, (array) ($this->recipient_milestones_sent ?? []), true);
}
public function markRecipientMilestoneSent(string $milestone): void
{
$sent = (array) ($this->recipient_milestones_sent ?? []);
$sent[] = $milestone;
$this->forceFill([
'recipient_milestones_sent' => array_values(array_unique($sent)),
])->save();
}
} }
@@ -0,0 +1,63 @@
<?php
namespace App\Notifications;
use App\Models\Transfer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TransferRecipientNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly Transfer $transfer,
private readonly string $milestone,
) {}
public function via(mixed $notifiable): array
{
return ['mail'];
}
public function toMail(mixed $notifiable): MailMessage
{
$transfer = $this->transfer->loadMissing(['files', 'qrCode', 'user']);
$publicUrl = $transfer->qrCode?->publicUrl() ?? '';
return (new MailMessage())
->subject($this->subjectLine())
->view('mail.notifications.transfer-recipient', [
'transfer' => $transfer,
'publicUrl' => $publicUrl,
'milestone' => $this->milestone,
'senderName' => $transfer->user?->name,
'daysRemaining' => $this->daysRemaining(),
]);
}
private function subjectLine(): string
{
return match ($this->milestone) {
'created' => $this->transfer->user?->name
? "{$this->transfer->user->name} shared files with you"
: "Files shared with you: {$this->transfer->title}",
'grace' => "Download soon — {$this->transfer->title} needs payment",
'1' => "Last day to download: {$this->transfer->title}",
'3' => "3 days left to download: {$this->transfer->title}",
'7' => "Download reminder: {$this->transfer->title}",
default => "Download reminder: {$this->transfer->title}",
};
}
private function daysRemaining(): ?int
{
if (! is_numeric($this->milestone)) {
return null;
}
return (int) $this->milestone;
}
}
+1 -1
View File
@@ -105,7 +105,7 @@ class AfiaService
What Ladill Transfer does and where things live: What Ladill Transfer does and where things live:
- Overview (Dashboard): active transfers, storage used, downloads in the last 30 days, transfers expiring soon, wallet balance. - Overview (Dashboard): active transfers, storage used, downloads in the last 30 days, transfers expiring soon, wallet balance.
- Transfers: list all file shares; create a new transfer (upload one or more files, title, optional message, retention period, optional password). - Transfers: list all file shares; create a new transfer (upload files, optional recipient email with milestone reminders, optional password).
- After creating a transfer: users get a share link and downloadable QR code; recipients scan ladill.com/q/{code} or open the link. - After creating a transfer: users get a share link and downloadable QR code; recipients scan ladill.com/q/{code} or open the link.
- Files: browse all stored files across transfers with sizes and download counts. - Files: browse all stored files across transfers with sizes and download counts.
- Analytics: download trends, top transfers, recent download activity. - Analytics: download trends, top transfers, recent download activity.
@@ -102,6 +102,8 @@ class TransferBillingService
'grace_ends_at' => $graceEndsAt, 'grace_ends_at' => $graceEndsAt,
'expires_at' => $graceEndsAt, 'expires_at' => $graceEndsAt,
]); ]);
app(TransferRecipientMailService::class)->notifyRecipient($transfer->fresh(), 'grace');
} }
public function processDueRenewals(): array public function processDueRenewals(): array
@@ -0,0 +1,99 @@
<?php
namespace App\Services\Transfer;
use App\Models\Transfer;
use App\Notifications\TransferRecipientNotification;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Notification;
/** Sends milestone emails to transfer recipients (share link + reminders). */
class TransferRecipientMailService
{
/** @return list<string> */
public function allowedMilestones(): array
{
return array_keys((array) config('transfer.recipient_email_milestones', []));
}
/** @return array<string, string> */
public function milestoneOptions(): array
{
return (array) config('transfer.recipient_email_milestones', []);
}
/** @param list<string> $milestones */
public function normalizeMilestones(array $milestones): array
{
$allowed = $this->allowedMilestones();
return array_values(array_unique(array_filter(
$milestones,
static fn ($milestone) => in_array((string) $milestone, $allowed, true),
)));
}
public function notifyRecipient(Transfer $transfer, string $milestone): bool
{
$email = trim((string) $transfer->recipient_email);
if ($email === '' || ! $transfer->wantsRecipientMilestone($milestone)) {
return false;
}
if ($transfer->recipientMilestoneSent($milestone)) {
return false;
}
Notification::route('mail', $email)->notify(
new TransferRecipientNotification($transfer->loadMissing(['files', 'qrCode', 'user']), $milestone),
);
$transfer->markRecipientMilestoneSent($milestone);
return true;
}
public function processDueMilestones(): int
{
$sent = 0;
Transfer::query()
->accessible()
->whereNotNull('recipient_email')
->whereNotNull('recipient_email_milestones')
->with(['files', 'qrCode', 'user'])
->chunkById(100, function ($transfers) use (&$sent) {
foreach ($transfers as $transfer) {
foreach ($this->dueDayMilestones($transfer) as $milestone) {
if ($this->notifyRecipient($transfer, $milestone)) {
$sent++;
}
}
}
});
return $sent;
}
/** @return list<string> */
private function dueDayMilestones(Transfer $transfer): array
{
if (! $transfer->paid_until instanceof CarbonInterface || $transfer->paid_until->isPast()) {
return [];
}
$daysRemaining = (int) now()->startOfDay()->diffInDays(
$transfer->paid_until->copy()->startOfDay(),
false,
);
$due = [];
foreach (['7', '3', '1'] as $milestone) {
if ($daysRemaining === (int) $milestone && $transfer->wantsRecipientMilestone($milestone)) {
$due[] = $milestone;
}
}
return $due;
}
}
+18 -1
View File
@@ -47,10 +47,24 @@ class TransferService
$passwordHash = Hash::make((string) $data['password']); $passwordHash = Hash::make((string) $data['password']);
} }
$recipientEmail = trim((string) ($data['recipient_email'] ?? ''));
$recipientMilestones = app(TransferRecipientMailService::class)->normalizeMilestones(
is_array($data['email_milestones'] ?? null) ? $data['email_milestones'] : [],
);
if ($recipientEmail !== '' && $recipientMilestones === []) {
$recipientMilestones = app(TransferRecipientMailService::class)->normalizeMilestones(
(array) config('transfer.default_recipient_email_milestones', ['created']),
);
}
$transfer = Transfer::create([ $transfer = Transfer::create([
'user_id' => $user->id, 'user_id' => $user->id,
'title' => trim((string) $data['title']), 'title' => trim((string) $data['title']),
'message' => isset($data['message']) ? trim((string) $data['message']) : null, 'message' => isset($data['message']) ? trim((string) $data['message']) : null,
'recipient_email' => $recipientEmail !== '' ? $recipientEmail : null,
'recipient_email_milestones' => $recipientEmail !== '' ? $recipientMilestones : null,
'recipient_milestones_sent' => [],
'password_hash' => $passwordHash, 'password_hash' => $passwordHash,
'retention_days' => $billingPeriodDays, 'retention_days' => $billingPeriodDays,
'status' => Transfer::STATUS_ACTIVE, 'status' => Transfer::STATUS_ACTIVE,
@@ -83,7 +97,10 @@ class TransferService
$transfer = $transfer->fresh(['files', 'qrCode']); $transfer = $transfer->fresh(['files', 'qrCode']);
app(TransferBillingService::class)->chargeInitialPeriod($transfer, $user); app(TransferBillingService::class)->chargeInitialPeriod($transfer, $user);
return $transfer->fresh(['files', 'qrCode']); $transfer = $transfer->fresh(['files', 'qrCode']);
app(TransferRecipientMailService::class)->notifyRecipient($transfer, 'created');
return $transfer;
}); });
} }
+11
View File
@@ -29,6 +29,17 @@ return [
// Days to keep files after a failed renewal before automatic deletion. // Days to keep files after a failed renewal before automatic deletion.
'grace_period_days' => (int) env('TRANSFER_GRACE_PERIOD_DAYS', 15), 'grace_period_days' => (int) env('TRANSFER_GRACE_PERIOD_DAYS', 15),
// Recipient email milestones (checkboxes on the create transfer form).
'recipient_email_milestones' => [
'created' => 'Send download link immediately',
'7' => '7 days before files become unavailable',
'3' => '3 days before files become unavailable',
'1' => '1 day before files become unavailable',
'grace' => 'When files enter the payment grace period',
],
'default_recipient_email_milestones' => ['created'],
// Legacy env keys (billing is monthly until cancelled by non-payment + grace). // Legacy env keys (billing is monthly until cancelled by non-payment + grace).
'default_retention_days' => (int) env('TRANSFER_DEFAULT_RETENTION_DAYS', 30), 'default_retention_days' => (int) env('TRANSFER_DEFAULT_RETENTION_DAYS', 30),
'mail_retention_days' => (int) env('TRANSFER_MAIL_RETENTION_DAYS', 30), 'mail_retention_days' => (int) env('TRANSFER_MAIL_RETENTION_DAYS', 30),
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('transfers', function (Blueprint $table) {
$table->string('recipient_email')->nullable()->after('message');
$table->json('recipient_email_milestones')->nullable()->after('recipient_email');
$table->json('recipient_milestones_sent')->nullable()->after('recipient_email_milestones');
});
}
public function down(): void
{
Schema::table('transfers', function (Blueprint $table) {
$table->dropColumn(['recipient_email', 'recipient_email_milestones', 'recipient_milestones_sent']);
});
}
};
@@ -0,0 +1,402 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{ $subject ?? 'Ladill Notification' }}</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style>
body {
margin: 0;
padding: 0;
width: 100%;
background-color: #f8fafc;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.email-wrapper {
width: 100%;
background-color: #f8fafc;
padding: 48px 0;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.email-header {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
padding: 32px 40px;
text-align: center;
}
.email-logo {
max-height: 36px;
width: auto;
}
.email-icon-header {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
padding: 48px 40px;
text-align: center;
}
.email-icon {
width: 64px;
height: 64px;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.email-icon svg {
width: 32px;
height: 32px;
color: #ffffff;
}
.email-header-title {
color: #ffffff;
font-size: 24px;
font-weight: 700;
margin: 0;
letter-spacing: -0.025em;
}
.email-header-subtitle {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
margin: 8px 0 0 0;
}
.email-body {
padding: 40px;
}
.email-title {
font-size: 24px;
font-weight: 700;
color: #0f172a;
margin: 0 0 16px 0;
line-height: 1.3;
letter-spacing: -0.025em;
}
.email-text {
font-size: 16px;
line-height: 1.7;
color: #475569;
margin: 0 0 16px 0;
}
.email-text-sm {
font-size: 14px;
line-height: 1.6;
color: #64748b;
margin: 0 0 12px 0;
}
.email-button {
display: inline-block;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: #ffffff !important;
text-decoration: none;
padding: 14px 32px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
margin: 24px 0;
box-shadow: 0 4px 14px 0 rgba(79, 70, 229, 0.4);
}
.email-button-secondary {
display: inline-block;
background-color: #f1f5f9;
color: #0f172a !important;
text-decoration: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
margin: 8px 4px;
}
.email-divider {
height: 1px;
background: linear-gradient(90deg, transparent, #e2e8f0, transparent);
margin: 32px 0;
}
.email-details {
background-color: #f8fafc;
border-radius: 12px;
padding: 24px;
margin: 24px 0;
border: 1px solid #e2e8f0;
}
.email-details-title {
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 16px 0;
}
.email-details-row {
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
}
.email-details-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.email-details-row:first-of-type {
padding-top: 0;
}
.email-details-label {
font-size: 13px;
color: #64748b;
margin: 0 0 4px 0;
}
.email-details-value {
font-size: 15px;
font-weight: 600;
color: #0f172a;
margin: 0;
word-break: break-all;
}
.email-card {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: 12px;
padding: 24px;
margin: 24px 0;
border: 1px solid #e2e8f0;
}
.email-card-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.email-card-icon svg {
width: 24px;
height: 24px;
color: #ffffff;
}
.email-footer {
background-color: #0f172a;
padding: 40px;
text-align: center;
}
.email-footer-text {
font-size: 13px;
color: #94a3b8;
margin: 0 0 8px 0;
line-height: 1.6;
}
.email-footer-link {
color: #a5b4fc;
text-decoration: none;
}
.email-footer-link:hover {
text-decoration: underline;
}
.email-footer-links {
margin: 20px 0;
}
.email-footer-links a {
color: #94a3b8;
text-decoration: none;
font-size: 13px;
margin: 0 12px;
}
.email-footer-links a:hover {
color: #ffffff;
}
.highlight-box {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
border-radius: 12px;
padding: 28px;
margin: 24px 0;
text-align: center;
}
.highlight-box-text {
color: #ffffff;
font-size: 20px;
font-weight: 700;
margin: 0;
letter-spacing: -0.025em;
}
.highlight-box-subtext {
color: rgba(255, 255, 255, 0.85);
font-size: 14px;
margin: 8px 0 0 0;
}
.highlight-box-success {
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
}
.highlight-box-warning {
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
}
.highlight-box-info {
background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%);
}
.status-badge {
display: inline-block;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-success {
background-color: #dcfce7;
color: #166534;
}
.status-warning {
background-color: #fef3c7;
color: #92400e;
}
.status-info {
background-color: #dbeafe;
color: #1e40af;
}
.status-error {
background-color: #fee2e2;
color: #991b1b;
}
.checklist-item {
display: flex;
align-items: flex-start;
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
}
.checklist-item:last-child {
border-bottom: none;
}
.checklist-icon {
width: 24px;
height: 24px;
background-color: #dcfce7;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
}
.checklist-icon svg {
width: 14px;
height: 14px;
color: #166534;
}
.checklist-content {
flex: 1;
}
.checklist-title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
margin: 0 0 2px 0;
}
.checklist-desc {
font-size: 13px;
color: #64748b;
margin: 0;
}
.code-block {
background-color: #1e293b;
border-radius: 8px;
padding: 16px 20px;
margin: 16px 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
color: #e2e8f0;
word-break: break-all;
}
.amount-large {
font-size: 36px;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.025em;
}
.amount-currency {
font-size: 18px;
color: #64748b;
font-weight: 500;
}
@media only screen and (max-width: 620px) {
.email-wrapper {
padding: 24px 16px;
}
.email-container {
border-radius: 12px;
}
.email-header,
.email-icon-header {
padding: 24px;
}
.email-body,
.email-footer {
padding: 28px 24px;
}
.email-title {
font-size: 20px;
}
.email-header-title {
font-size: 20px;
}
.amount-large {
font-size: 28px;
}
}
</style>
</head>
<body>
<div class="email-wrapper">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<div class="email-container">
{{-- Header with Logo --}}
<div class="email-header">
<img src="{{ config('app.url') }}/images/logo/ladill-logo-white.png" alt="Ladill" class="email-logo">
</div>
@hasSection('icon-header')
@yield('icon-header')
@endif
{{-- Email Body --}}
<div class="email-body">
@yield('content')
</div>
{{-- Footer --}}
<div class="email-footer">
<p class="email-footer-text">
You're receiving this email because you have an account with Ladill.
</p>
<div class="email-footer-links">
<a href="{{ config('app.url') }}/dashboard">Dashboard</a>
<a href="{{ config('app.url') }}/support">Support</a>
<a href="{{ config('app.url') }}">ladill.com</a>
</div>
<p class="email-footer-text" style="margin-top: 20px; color: #64748b;">
&copy; 2026 Ladill Technologies. All rights reserved.
</p>
</div>
</div>
</td>
</tr>
</table>
</div>
</body>
</html>
@@ -0,0 +1,76 @@
@extends('mail.notifications.layout')
@section('content')
@php
$fileCount = $transfer->files->count();
$availableUntil = $transfer->isInGracePeriod()
? $transfer->grace_ends_at
: $transfer->paid_until;
@endphp
<div class="highlight-box highlight-box-success">
<p class="highlight-box-text">📁 File transfer</p>
<p class="highlight-box-subtext">{{ $transfer->title }}</p>
</div>
@if($milestone === 'created')
<h1 class="email-title">You have files to download</h1>
<p class="email-text">
@if($senderName)
<strong>{{ $senderName }}</strong> shared {{ $fileCount === 1 ? 'a file' : $fileCount.' files' }} with you via Ladill Transfer.
@else
Someone shared {{ $fileCount === 1 ? 'a file' : $fileCount.' files' }} with you via Ladill Transfer.
@endif
</p>
@elseif($milestone === 'grace')
<h1 class="email-title">Download before these files are removed</h1>
<p class="email-text">
The transfer <strong>{{ $transfer->title }}</strong> is in a grace period.
@if($availableUntil)
Files will be deleted on <strong>{{ $availableUntil->format('F j, Y') }}</strong> unless payment is received.
@endif
</p>
@else
<h1 class="email-title">Reminder: download your files</h1>
<p class="email-text">
<strong>{{ $transfer->title }}</strong>
@if($daysRemaining === 1)
will become unavailable tomorrow.
@else
will become unavailable in {{ $daysRemaining }} days.
@endif
</p>
@endif
@if($transfer->message)
<p class="email-text" style="background:#f8fafc;border-radius:10px;padding:14px 16px;color:#475569;">
{{ $transfer->message }}
</p>
@endif
@if($transfer->isPasswordProtected())
<p class="email-text" style="font-size:13px;color:#b45309;">
This transfer is password protected. The sender will need to share the password with you separately.
</p>
@endif
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin:24px 0;">
<tr>
<td align="center">
<a href="{{ $publicUrl }}" class="email-button" style="display:inline-block;background:#4f46e5;color:#ffffff;text-decoration:none;padding:12px 28px;border-radius:10px;font-size:14px;font-weight:600;">
Download files
</a>
</td>
</tr>
</table>
<p class="email-text" style="font-size:13px;color:#6b7280;">
Or open this link: <a href="{{ $publicUrl }}" style="color:#4f46e5;">{{ $publicUrl }}</a>
</p>
@if($availableUntil && $milestone !== 'grace')
<p class="email-text" style="font-size:13px;color:#6b7280;">
Available until {{ $availableUntil->format('F j, Y') }}.
</p>
@endif
@endsection
@@ -51,6 +51,27 @@
@error('files.*')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror @error('files.*')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
</div> </div>
<div>
<label for="recipient_email" class="block text-sm font-medium text-slate-700">Recipient email <span class="font-normal text-slate-400">(optional)</span></label>
<input type="email" name="recipient_email" id="recipient_email" value="{{ old('recipient_email') }}" maxlength="255"
placeholder="friend@example.com"
class="mt-1 block w-full rounded-xl border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
@error('recipient_email')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
<p class="mt-2 text-xs font-medium text-slate-600">Email milestones</p>
<div class="mt-2 space-y-2">
@foreach($emailMilestones as $id => $label)
<label class="flex items-start gap-2 text-sm text-slate-600">
<input type="checkbox" name="email_milestones[]" value="{{ $id }}"
@checked(in_array($id, old('email_milestones', $defaultEmailMilestones), true))
class="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>{{ $label }}</span>
</label>
@endforeach
</div>
@error('email_milestones')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
@error('email_milestones.*')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
</div>
<div class="rounded-xl border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-sm text-slate-600"> <div class="rounded-xl border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-sm text-slate-600">
<p class="font-medium text-slate-900">Monthly storage billing</p> <p class="font-medium text-slate-900">Monthly storage billing</p>
<p class="mt-1">GHS {{ number_format($pricePerGb, 2) }} per GB per month, debited from your wallet. Files stay available while payments succeed. If a monthly renewal fails, files are kept for {{ $gracePeriodDays }} days before deletion.</p> <p class="mt-1">GHS {{ number_format($pricePerGb, 2) }} per GB per month, debited from your wallet. Files stay available while payments succeed. If a monthly renewal fails, files are kept for {{ $gracePeriodDays }} days before deletion.</p>
@@ -35,6 +35,16 @@
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div> <div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
@endif @endif
@if($transfer->recipient_email)
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
Recipient: <span class="font-medium text-slate-900">{{ $transfer->recipient_email }}</span>
@if(is_array($transfer->recipient_email_milestones) && $transfer->recipient_email_milestones !== [])
<span class="text-slate-400">·</span>
Notified on {{ implode(', ', array_map(fn ($m) => $m === 'created' ? 'share' : $m, $transfer->recipient_email_milestones)) }}
@endif
</div>
@endif
<div class="grid gap-6 lg:grid-cols-2"> <div class="grid gap-6 lg:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-white p-6"> <div class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="font-semibold text-slate-900">Share link</h2> <h2 class="font-semibold text-slate-900">Share link</h2>
@@ -0,0 +1,78 @@
<?php
namespace Tests\Feature;
use App\Models\Transfer;
use App\Models\User;
use App\Notifications\TransferRecipientNotification;
use App\Services\Transfer\TransferRecipientMailService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\Concerns\FakesTransferBilling;
use Tests\TestCase;
class TransferRecipientMailTest extends TestCase
{
use FakesTransferBilling;
use RefreshDatabase;
public function test_create_transfer_emails_recipient_when_created_milestone_selected(): void
{
Notification::fake();
Storage::fake('qr');
$this->fakeTransferBillingApi();
$user = User::create([
'public_id' => (string) Str::uuid(),
'name' => 'Sender',
'email' => 'sender+'.uniqid().'@example.com',
]);
$this->actingAs($user)->post(route('transfer.transfers.store'), [
'title' => 'Project files',
'recipient_email' => 'recipient@example.com',
'email_milestones' => ['created'],
'files' => [UploadedFile::fake()->create('brief.pdf', 100, 'application/pdf')],
])->assertRedirect();
Notification::assertSentOnDemand(
TransferRecipientNotification::class,
fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'recipient@example.com',
);
}
public function test_recipient_day_milestone_is_sent_once(): void
{
Notification::fake();
$user = User::create([
'public_id' => (string) Str::uuid(),
'name' => 'Sender',
'email' => 'sender+'.uniqid().'@example.com',
]);
$transfer = Transfer::create([
'user_id' => $user->id,
'title' => 'Reminder test',
'recipient_email' => 'recipient@example.com',
'recipient_email_milestones' => ['7'],
'recipient_milestones_sent' => [],
'retention_days' => 30,
'paid_until' => now()->addDays(7)->startOfDay(),
'status' => Transfer::STATUS_ACTIVE,
'storage_bytes' => 1024,
]);
$service = app(TransferRecipientMailService::class);
$this->assertSame(1, $service->processDueMilestones());
$this->assertSame(0, $service->processDueMilestones());
$transfer->refresh();
$this->assertContains('7', $transfer->recipient_milestones_sent ?? []);
Notification::assertSentOnDemand(TransferRecipientNotification::class, 1);
}
}