Skip to content

Commit 56d2d95

Browse files
committed
implemented email reply processor
1 parent 6956a29 commit 56d2d95

File tree

13 files changed

+3533
-1061
lines changed

13 files changed

+3533
-1061
lines changed

classes/editorialTask/EditorialTask.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ protected function fillHeadnote(array $attributes): array
325325
'userId' => $attributes['createdBy'] ?? $this->createdBy,
326326
'contents' => $attributes[self::ATTRIBUTE_HEADNOTE],
327327
'isHeadnote' => true,
328+
'messageId' => Note::generateMessageId(),
328329
]);
329330

330331
unset($attributes[self::ATTRIBUTE_HEADNOTE]);

classes/editorialTask/Repository.php

Lines changed: 195 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
use PKP\security\Role;
3333
use PKP\stageAssignment\StageAssignment;
3434
use PKP\user\User;
35+
use PKP\mail\mailables\DiscussionSubmission;
36+
use PKP\mail\mailables\DiscussionReview;
37+
use PKP\mail\mailables\DiscussionCopyediting;
38+
use PKP\mail\mailables\DiscussionProduction;
39+
3540

3641
class Repository
3742
{
@@ -46,7 +51,7 @@ public function countOpenPerStage(int $submissionId, ?array $participantIds = nu
4651
{
4752
$counts = EditorialTask::withAssoc(Application::ASSOC_TYPE_SUBMISSION, $submissionId)
4853
->when($participantIds !== null, function ($q) use ($participantIds) {
49-
$q->withUserIds($participantIds);
54+
$q->withParticipantIds($participantIds);
5055
})
5156
->withClosed(false)
5257
->selectRaw('stage_id, COUNT(stage_id) as count')
@@ -96,15 +101,21 @@ public function addQuery(int $submissionId, int $stageId, string $title, string
96101
'seq' => $maxSeq + 1,
97102
'createdBy' => $fromUser->getId(),
98103
'type' => EditorialTaskType::DISCUSSION->value,
99-
EditorialTask::ATTRIBUTE_PARTICIPANTS => array_map(fn (int $participantId) => ['userId' => $participantId], array_unique($participantUserIds)),
104+
EditorialTask::ATTRIBUTE_PARTICIPANTS => array_map(
105+
fn (int $participantId) => ['userId' => $participantId],
106+
array_unique($participantUserIds)
107+
),
100108
'title' => $title,
101109
]);
102110

103-
Note::create([
111+
// Head note for this discussion, with a capturable messageId
112+
$headNote = Note::create([
104113
'assocType' => Application::ASSOC_TYPE_QUERY,
105114
'assocId' => $task->id,
106115
'contents' => $content,
107116
'userId' => $fromUser->getId(),
117+
'isHeadnote' => true,
118+
'messageId' => Note::generateMessageId(),
108119
]);
109120

110121
// Add task for assigned participants
@@ -113,8 +124,24 @@ public function addQuery(int $submissionId, int $stageId, string $title, string
113124
/** @var NotificationSubscriptionSettingsDAO $notificationSubscriptionSettingsDao */
114125
$notificationSubscriptionSettingsDao = DAORegistry::getDAO('NotificationSubscriptionSettingsDAO');
115126

127+
// need submission + context + stage mailables to send capturable email
128+
$submission = Repo::submission()->get($submissionId);
129+
$application = Application::get();
130+
$request = $application->getRequest();
131+
$context = $request?->getContext();
132+
133+
$mailableMap = [
134+
WORKFLOW_STAGE_ID_SUBMISSION => DiscussionSubmission::class,
135+
WORKFLOW_STAGE_ID_INTERNAL_REVIEW => DiscussionReview::class,
136+
WORKFLOW_STAGE_ID_EXTERNAL_REVIEW => DiscussionReview::class,
137+
WORKFLOW_STAGE_ID_EDITING => DiscussionCopyediting::class,
138+
WORKFLOW_STAGE_ID_PRODUCTION => DiscussionProduction::class,
139+
];
140+
141+
$mailableClass = $mailableMap[$stageId] ?? null;
142+
116143
foreach ($task->participants()->get() as $participant) {
117-
$notificationMgr->createNotification(
144+
$notification = $notificationMgr->createNotification(
118145
$participant->userId,
119146
Notification::NOTIFICATION_TYPE_NEW_QUERY,
120147
$contextId,
@@ -123,7 +150,8 @@ public function addQuery(int $submissionId, int $stageId, string $title, string
123150
Notification::NOTIFICATION_LEVEL_TASK
124151
);
125152

126-
if (!$sendEmail) {
153+
if (
154+
!$sendEmail|| !$notification || !$mailableClass || !$submission || !$context) {
127155
continue;
128156
}
129157

@@ -138,11 +166,20 @@ public function addQuery(int $submissionId, int $stageId, string $title, string
138166
}
139167

140168
$recipient = $participant->user;
141-
$mailable = new Mailable();
142-
$mailable->to($recipient->getEmail(), $recipient->getFullName());
143-
$mailable->from($fromUser->getEmail(), $fromUser->getFullName());
144-
$mailable->subject($title);
145-
$mailable->body($content);
169+
if (!$recipient) {
170+
continue;
171+
}
172+
173+
/** @var \PKP\mail\Mailable $mailable */
174+
$mailable = new $mailableClass($context, $submission);
175+
176+
$mailable
177+
->sender($fromUser)
178+
->recipients([$recipient])
179+
->subject($title)
180+
->body($content)
181+
->allowUnsubscribe($notification)
182+
->allowCapturableReply($headNote->messageId);
146183

147184
Mail::send($mailable);
148185
}
@@ -240,12 +277,158 @@ private function taskAlreadyCreatedFromTemplate(int $submissionId, int $template
240277
public function deleteBySubmissionId(int $submissionId): void
241278
{
242279
$editorialTasks = EditorialTask::withAssoc(PKPApplication::ASSOC_TYPE_SUBMISSION, $submissionId)->get();
243-
$taskIds = $editorialTasks->pluck('query_id')->all();
280+
$taskIds = $editorialTasks->pluck('id')->all();
244281

245282
if (!empty($taskIds)) {
246-
EditorialTask::whereIn('query_id', $taskIds)->delete();
283+
EditorialTask::whereIn('edit_task_id', $taskIds)->delete();
247284
Note::whereIn('assoc_id', $taskIds)->delete();
248285
Notification::whereIn('assoc_id', $taskIds)->delete();
249286
}
250287
}
288+
289+
public function notifyParticipantsOnNote(Note $note): void
290+
{
291+
// Only discussion notes
292+
if ($note->assocType !== PKPApplication::ASSOC_TYPE_QUERY) {
293+
return;
294+
}
295+
296+
// skip headnote initial email handled when query is created
297+
if ($note->isHeadnote ?? false) {
298+
return;
299+
}
300+
301+
$task = EditorialTask::find($note->assocId);
302+
if (!$task) {
303+
return;
304+
}
305+
306+
$submission = Repo::submission()->get($task->assocId);
307+
if (!$submission) {
308+
return;
309+
}
310+
311+
$application = Application::get();
312+
$request = $application->getRequest();
313+
$context = $request->getContext();
314+
if (!$context) {
315+
return;
316+
}
317+
318+
$sender = Repo::user()->get($note->userId ?? null);
319+
if (!$sender) {
320+
return;
321+
}
322+
323+
$headNote = Repo::note()->getHeadNote($task->id);
324+
$threadAnchorMessageId = $headNote?->messageId;
325+
$title = $headNote?->title ?: $task->title;
326+
$subject = $title
327+
? __('common.re') . ' ' . $title
328+
: __('common.re');
329+
330+
331+
$participantIds = $task->participants()
332+
->pluck('user_id')
333+
->all();
334+
335+
if (empty($participantIds)) {
336+
return;
337+
}
338+
339+
/** @var NotificationSubscriptionSettingsDAO $notificationSubscriptionSettingsDao */
340+
$notificationSubscriptionSettingsDao = DAORegistry::getDAO('NotificationSubscriptionSettingsDAO');
341+
342+
$notificationManager = new NotificationManager();
343+
344+
// attachments for this note (if any)
345+
$submissionFiles = Repo::submissionFile()
346+
->getCollector()
347+
->filterByAssoc(PKPApplication::ASSOC_TYPE_NOTE, [$note->id])
348+
->filterBySubmissionIds([$submission->getId()])
349+
->getMany();
350+
351+
// Stage -> mailable map (same as StageMailable)
352+
$mailableMap = [
353+
WORKFLOW_STAGE_ID_SUBMISSION => DiscussionSubmission::class,
354+
WORKFLOW_STAGE_ID_INTERNAL_REVIEW => DiscussionReview::class,
355+
WORKFLOW_STAGE_ID_EXTERNAL_REVIEW => DiscussionReview::class,
356+
WORKFLOW_STAGE_ID_EDITING => DiscussionCopyediting::class,
357+
WORKFLOW_STAGE_ID_PRODUCTION => DiscussionProduction::class,
358+
];
359+
360+
if (!isset($mailableMap[$task->stageId])) {
361+
return;
362+
}
363+
364+
$mailableClass = $mailableMap[$task->stageId];
365+
366+
foreach ($participantIds as $userId) {
367+
if ($userId === $sender->getId()) {
368+
continue;
369+
}
370+
371+
// clear previous "query activity" notifications for this user/query
372+
Notification::withAssoc(PKPApplication::ASSOC_TYPE_QUERY, $task->id)
373+
->withUserId($userId)
374+
->withType(Notification::NOTIFICATION_TYPE_QUERY_ACTIVITY)
375+
->withContextId((int) $context->getId())
376+
->delete();
377+
378+
$recipient = Repo::user()->get($userId);
379+
if (!$recipient) {
380+
continue;
381+
}
382+
383+
// create notification
384+
$notification = $notificationManager->createNotification(
385+
$userId,
386+
Notification::NOTIFICATION_TYPE_QUERY_ACTIVITY,
387+
(int) $context->getId(),
388+
PKPApplication::ASSOC_TYPE_QUERY,
389+
$task->id,
390+
Notification::NOTIFICATION_LEVEL_TASK
391+
);
392+
393+
if (!$notification) {
394+
continue;
395+
}
396+
397+
// respect email notification settings
398+
$blocked = $notificationSubscriptionSettingsDao->getNotificationSubscriptionSettings(
399+
NotificationSubscriptionSettingsDAO::BLOCKED_EMAIL_NOTIFICATION_KEY,
400+
$userId,
401+
(int) $context->getId()
402+
);
403+
404+
if (in_array(Notification::NOTIFICATION_TYPE_QUERY_ACTIVITY, $blocked)) {
405+
continue;
406+
}
407+
408+
/** @var \PKP\mail\Mailable $mailable */
409+
$mailable = new $mailableClass($context, $submission);
410+
411+
$mailable
412+
->sender($sender)
413+
->recipients([$recipient])
414+
->subject($subject)
415+
->body($note->contents)
416+
->allowUnsubscribe($notification)
417+
->allowCapturableReply(
418+
$note->messageId,
419+
$threadAnchorMessageId && $threadAnchorMessageId !== $note->messageId ? $threadAnchorMessageId : null,
420+
$threadAnchorMessageId ? [$threadAnchorMessageId] : []
421+
);
422+
423+
$submissionFiles->each(
424+
fn ($item) => $mailable->attachSubmissionFile(
425+
$item->getId(),
426+
$item->getData('name')
427+
)
428+
);
429+
430+
Mail::send($mailable);
431+
}
432+
}
433+
251434
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
/**
4+
* @file classes/mail/traits/CapturableReply.php
5+
*
6+
* Copyright (c) 2024 Simon Fraser University
7+
* Copyright (c) 2024 John Willinsky
8+
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
9+
*
10+
* @class CapturableReply
11+
*
12+
* @ingroup mail_traits
13+
*
14+
* @brief Mailable trait to add support for capturable replies
15+
*/
16+
17+
namespace PKP\mail\traits;
18+
19+
use Illuminate\Mail\Mailables\Headers;
20+
use PKP\config\Config;
21+
22+
trait CapturableReply
23+
{
24+
protected ?string $messageId = null;
25+
protected ?string $inReplyTo = null;
26+
protected array $references = [];
27+
28+
/**
29+
* Adds headers that can be used to determine what a reply is in response to
30+
*/
31+
public function headers(): Headers
32+
{
33+
$textHeaders = [];
34+
35+
if ($this->inReplyTo) {
36+
$textHeaders['In-Reply-To'] = $this->inReplyTo;
37+
}
38+
39+
return new Headers(
40+
messageId: $this->messageId ?: null,
41+
references: $this->references,
42+
text: $textHeaders,
43+
);
44+
}
45+
46+
public function setupCapturableReply()
47+
{
48+
if ($replyTo = Config::getVar('email', 'reply_to_address')) {
49+
$this->replyTo($replyTo);
50+
}
51+
}
52+
53+
public function allowCapturableReply(
54+
?string $messageId,
55+
?string $inReplyTo = null,
56+
array $references = []
57+
): static {
58+
$this->messageId = $messageId;
59+
$this->inReplyTo = $inReplyTo;
60+
$this->references = $references;
61+
62+
return $this;
63+
}
64+
}

classes/mail/traits/Discussion.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,40 @@
1616

1717
namespace PKP\mail\traits;
1818

19+
use Illuminate\Mail\Mailables\Headers;
1920
use PKP\mail\variables\SubmissionEmailVariable;
2021

2122
trait Discussion
2223
{
23-
use Unsubscribe {
24-
getRequiredVariables as getTraitRequiredVariables;
24+
use Unsubscribe, CapturableReply {
25+
Unsubscribe::getRequiredVariables as getTraitRequiredVariables;
26+
Unsubscribe::headers as unsubscribeHeaders;
27+
CapturableReply::headers as capturableReplyHeaders;
28+
}
29+
30+
/**
31+
* Merge the headers from the competing traits together.
32+
*/
33+
public function headers(): Headers
34+
{
35+
$unsubscribeHeaders = $this->unsubscribeHeaders();
36+
$capturableReplyHeaders = $this->capturableReplyHeaders();
37+
38+
if ($unsubscribeHeaders->messageId !== null) {
39+
throw new \Exception('Unable to merge message IDs!');
40+
}
41+
42+
return new Headers(
43+
$capturableReplyHeaders->messageId,
44+
array_merge($unsubscribeHeaders->references, $capturableReplyHeaders->references),
45+
array_merge($unsubscribeHeaders->text, $capturableReplyHeaders->text)
46+
);
2547
}
2648

2749
protected function addFooter(string $locale): self
2850
{
2951
$this->setupUnsubscribeFooter($locale, $this->context);
52+
$this->setupCapturableReply();
3053
return $this;
3154
}
3255

classes/migration/install/NotesMigration.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ public function up(): void
3636

3737
$table->datetime('date_created');
3838
$table->datetime('date_modified')->nullable();
39+
$table->string('message_id', 255)->nullable();
3940
$table->text('contents')->nullable();
4041
$table->boolean('is_headnote')->default(false);
4142

4243
$table->index(['assoc_type', 'assoc_id'], 'notes_assoc');
44+
$table->index(['message_id'], 'notes_message_id');
4345
});
4446
}
4547

0 commit comments

Comments
 (0)