feat(email): add format selection to email book dialog (#2650)

Allow users to choose which file format to send when emailing a book.
Includes file size display and large file warnings for files >25MB.

Backend:
- Add optional bookFileId to SendBookByEmailRequest
- Add resolveBookFile() to resolve specific file or fallback to primary
- Update SendEmailV2Service to use specified file path

Frontend:
- Change openCustomSendDialog to accept Book object
- Add format selection with radio buttons showing type/size
- Add "Primary" badge and large file warning banner
- Update book-card and metadata-viewer callers

Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
ACX
2026-02-07 12:28:21 -07:00
committed by GitHub
parent 9392caa991
commit b2a4aa7960
9 changed files with 204 additions and 17 deletions

View File

@@ -20,4 +20,6 @@ public class SendBookByEmailRequest {
@NotNull(message = "Recipient ID cannot be null")
private Long recipientId;
private Long bookFileId; // Optional: if null, uses primary file
}

View File

@@ -5,6 +5,7 @@ import org.booklore.exception.ApiError;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.request.SendBookByEmailRequest;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookFileEntity;
import org.booklore.model.entity.EmailProviderV2Entity;
import org.booklore.model.entity.EmailRecipientV2Entity;
import org.booklore.model.entity.UserEmailProviderPreferenceEntity;
@@ -46,7 +47,8 @@ public class SendEmailV2Service {
BookEntity book = bookRepository.findByIdWithBookFiles(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
EmailProviderV2Entity defaultEmailProvider = getDefaultEmailProvider();
EmailRecipientV2Entity defaultEmailRecipient = emailRecipientRepository.findDefaultEmailRecipientByUserId(user.getId()).orElseThrow(ApiError.DEFAULT_EMAIL_RECIPIENT_NOT_FOUND::createException);
sendEmailInVirtualThread(defaultEmailProvider, defaultEmailRecipient.getEmail(), book);
BookFileEntity bookFile = book.getPrimaryBookFile();
sendEmailInVirtualThread(defaultEmailProvider, defaultEmailRecipient.getEmail(), book, bookFile);
}
public void emailBook(SendBookByEmailRequest request) {
@@ -58,17 +60,18 @@ public class SendEmailV2Service {
);
BookEntity book = bookRepository.findByIdWithBookFiles(request.getBookId()).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(request.getBookId()));
EmailRecipientV2Entity emailRecipient = emailRecipientRepository.findByIdAndUserId(request.getRecipientId(), user.getId()).orElseThrow(() -> ApiError.EMAIL_RECIPIENT_NOT_FOUND.createException(request.getRecipientId()));
sendEmailInVirtualThread(emailProvider, emailRecipient.getEmail(), book);
BookFileEntity bookFile = resolveBookFile(book, request.getBookFileId());
sendEmailInVirtualThread(emailProvider, emailRecipient.getEmail(), book, bookFile);
}
private void sendEmailInVirtualThread(EmailProviderV2Entity emailProvider, String recipientEmail, BookEntity book) {
private void sendEmailInVirtualThread(EmailProviderV2Entity emailProvider, String recipientEmail, BookEntity book, BookFileEntity bookFile) {
String bookTitle = book.getMetadata().getTitle();
String logMessage = "Email dispatch initiated for book: " + bookTitle + " to " + recipientEmail;
notificationService.sendMessage(Topic.LOG, LogNotification.info(logMessage));
log.info(logMessage);
SecurityContextVirtualThread.runWithSecurityContext(() -> {
try {
sendEmail(emailProvider, recipientEmail, book);
sendEmail(emailProvider, recipientEmail, book, bookFile);
String successMessage = "The book: " + bookTitle + " has been successfully sent to " + recipientEmail;
notificationService.sendMessage(Topic.LOG, LogNotification.info(successMessage));
log.info(successMessage);
@@ -80,7 +83,7 @@ public class SendEmailV2Service {
});
}
private void sendEmail(EmailProviderV2Entity emailProvider, String recipientEmail, BookEntity book) throws MessagingException {
private void sendEmail(EmailProviderV2Entity emailProvider, String recipientEmail, BookEntity book, BookFileEntity bookFileEntity) throws MessagingException {
JavaMailSenderImpl dynamicMailSender = setupMailSender(emailProvider);
MimeMessage message = dynamicMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
@@ -88,12 +91,22 @@ public class SendEmailV2Service {
helper.setTo(recipientEmail);
helper.setSubject("Your Book from Booklore: " + book.getMetadata().getTitle());
helper.setText(generateEmailBody(book.getMetadata().getTitle()));
File bookFile = new File(FileUtils.getBookFullPath(book));
File bookFile = new File(FileUtils.getBookFullPath(book, bookFileEntity));
helper.addAttachment(bookFile.getName(), bookFile);
dynamicMailSender.send(message);
log.info("Book sent successfully to {}", recipientEmail);
}
private BookFileEntity resolveBookFile(BookEntity book, Long bookFileId) {
if (bookFileId == null) {
return book.getPrimaryBookFile();
}
return book.getBookFiles().stream()
.filter(bf -> bf.getId().equals(bookFileId))
.findFirst()
.orElseThrow(() -> ApiError.FILE_NOT_FOUND.createException(bookFileId));
}
private JavaMailSenderImpl setupMailSender(EmailProviderV2Entity emailProvider) {
JavaMailSenderImpl dynamicMailSender = new JavaMailSenderImpl();
dynamicMailSender.setHost(emailProvider.getHost());

View File

@@ -395,7 +395,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
label: 'Custom Send',
icon: 'pi pi-envelope',
command: () => {
this.bookDialogHelperService.openCustomSendDialog(this.book.id);
this.bookDialogHelperService.openCustomSendDialog(this.book);
}
}
]

View File

@@ -114,12 +114,12 @@ export class BookDialogHelperService {
});
}
openCustomSendDialog(bookId: number): DynamicDialogRef | null {
openCustomSendDialog(book: Book): DynamicDialogRef | null {
return this.openDialog(BookSenderComponent, {
showHeader: false,
styleClass: `${DialogSize.SM} ${DialogStyle.MINIMAL}`,
data: {
bookId: bookId,
book: book,
},
});
}

View File

@@ -43,6 +43,31 @@
styleClass="full-width">
</p-select>
</div>
@if (emailableFiles.length > 0) {
<div class="form-field">
<label class="field-label">File Format</label>
<div class="format-options">
@for (file of emailableFiles; track file.id) {
<div class="format-option" [class.selected]="selectedFileId === file.id">
<p-radioButton name="fileFormat" [value]="file.id" [(ngModel)]="selectedFileId"/>
<div class="format-info">
<span class="format-type">{{ file.bookType || 'Unknown' }}</span>
@if (file.isPrimary) { <span class="primary-badge">Primary</span> }
<span class="file-size">{{ formatFileSize(file.fileSizeKb) }}</span>
</div>
</div>
}
</div>
</div>
}
@if (showLargeFileWarning) {
<div class="warning-banner">
<i class="pi pi-exclamation-triangle"></i>
<span>This file exceeds 25MB. Some email providers may reject large attachments.</span>
</div>
}
</div>
</div>

View File

@@ -46,3 +46,79 @@
:host ::ng-deep .full-width {
width: 100%;
}
.format-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.format-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 0.03);
border-color: var(--p-primary-400);
}
&.selected {
background: rgba(var(--p-primary-500-rgb), 0.1);
border-color: var(--p-primary-500);
}
}
.format-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.format-type {
font-weight: 500;
color: #e5e7eb;
}
.primary-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
background: var(--p-primary-500);
color: white;
border-radius: 4px;
text-transform: uppercase;
}
.file-size {
margin-left: auto;
font-size: 0.8rem;
color: #9ca3af;
}
.warning-banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.3);
border-radius: 6px;
color: #fbbf24;
i {
font-size: 1rem;
margin-top: 0.125rem;
}
span {
font-size: 0.85rem;
line-height: 1.4;
}
}

View File

@@ -9,13 +9,25 @@ import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {MessageService} from 'primeng/api';
import {EmailV2ProviderService} from '../../../settings/email-v2/email-v2-provider/email-v2-provider.service';
import {EmailV2RecipientService} from '../../../settings/email-v2/email-v2-recipient/email-v2-recipient.service';
import {Book, BookFile} from '../../model/book.model';
import {RadioButton} from 'primeng/radiobutton';
interface EmailableFile {
id: number;
bookType?: string;
fileSizeKb?: number;
isPrimary: boolean;
}
const LARGE_FILE_THRESHOLD_KB = 25 * 1024; // 25MB
@Component({
selector: 'app-book-sender',
imports: [
Button,
Select,
FormsModule
FormsModule,
RadioButton
],
templateUrl: './book-sender.component.html',
styleUrls: ['./book-sender.component.scss']
@@ -29,14 +41,19 @@ export class BookSenderComponent implements OnInit {
dynamicDialogRef = inject(DynamicDialogRef);
private dynamicDialogConfig = inject(DynamicDialogConfig);
bookId: number = this.dynamicDialogConfig.data.bookId;
book: Book = this.dynamicDialogConfig.data.book;
emailProviders: { label: string, value: EmailProvider }[] = [];
emailRecipients: { label: string, value: EmailRecipient }[] = [];
selectedProvider?: { label: string; value: EmailProvider };
selectedRecipient?: { label: string; value: EmailRecipient };
emailableFiles: EmailableFile[] = [];
selectedFileId?: number;
ngOnInit(): void {
this.buildEmailableFiles();
this.emailProviderService.getEmailProviders().subscribe({
next: (emailProviders: EmailProvider[]) => {
this.emailProviders = emailProviders.map(provider => ({
@@ -56,18 +73,72 @@ export class BookSenderComponent implements OnInit {
});
}
private buildEmailableFiles(): void {
this.emailableFiles = [];
// Add primary file
if (this.book.primaryFile) {
this.emailableFiles.push({
id: this.book.primaryFile.id,
bookType: this.book.primaryFile.bookType,
fileSizeKb: this.book.primaryFile.fileSizeKb,
isPrimary: true
});
this.selectedFileId = this.book.primaryFile.id;
}
// Add alternative formats (only book formats, not supplementary files)
if (this.book.alternativeFormats) {
for (const format of this.book.alternativeFormats) {
this.emailableFiles.push({
id: format.id,
bookType: format.bookType,
fileSizeKb: format.fileSizeKb,
isPrimary: false
});
}
}
}
get showLargeFileWarning(): boolean {
if (!this.selectedFileId) return false;
const selectedFile = this.emailableFiles.find(f => f.id === this.selectedFileId);
return !!selectedFile?.fileSizeKb && selectedFile.fileSizeKb > LARGE_FILE_THRESHOLD_KB;
}
formatFileSize(fileSizeKb?: number): string {
if (fileSizeKb == null) return '-';
const units = ['KB', 'MB', 'GB', 'TB'];
let size = fileSizeKb;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const decimals = size >= 100 ? 0 : size >= 10 ? 1 : 2;
return `${size.toFixed(decimals)} ${units[unitIndex]}`;
}
sendBook() {
if (this.selectedProvider && this.selectedRecipient && this.bookId) {
const bookId = this.bookId;
if (this.selectedProvider && this.selectedRecipient && this.book?.id) {
const bookId = this.book.id;
const recipientId = this.selectedRecipient.value.id;
const providerId = this.selectedProvider.value.id;
const emailRequest = {
const emailRequest: { bookId: number, providerId: number, recipientId: number, bookFileId?: number } = {
bookId,
providerId,
recipientId: recipientId,
};
// Include bookFileId if a specific file is selected and it's not the primary
if (this.selectedFileId) {
emailRequest.bookFileId = this.selectedFileId;
}
this.emailService.emailBook(emailRequest).subscribe({
next: () => {
this.messageService.add({
@@ -101,7 +172,7 @@ export class BookSenderComponent implements OnInit {
detail: 'Please select a recipient to send the book.'
});
}
if (!this.bookId) {
if (!this.book?.id) {
this.messageService.add({
severity: 'error',
summary: 'Book Not Selected',

View File

@@ -256,7 +256,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
label: 'Custom Send',
icon: 'pi pi-cog',
command: () => {
this.bookDialogHelperService.openCustomSendDialog(book.id);
this.bookDialogHelperService.openCustomSendDialog(book);
}
}
]

View File

@@ -12,7 +12,7 @@ export class EmailService {
private http = inject(HttpClient);
emailBook(request: { bookId: number, providerId: number, recipientId: number }): Observable<void> {
emailBook(request: { bookId: number, providerId: number, recipientId: number, bookFileId?: number }): Observable<void> {
return this.http.post<void>(`${this.apiUrl}/book`, request);
}