mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user