Skip to content

Commit c8bdd1b

Browse files
committed
Process email replies into discussions
1 parent 9125b99 commit c8bdd1b

File tree

13 files changed

+3531
-1059
lines changed

13 files changed

+3531
-1059
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: 193 additions & 10 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
}
@@ -250,6 +287,152 @@ public function deleteBySubmissionId(int $submissionId): void
250287
}
251288
}
252289

290+
public function notifyParticipantsOnNote(Note $note): void
291+
{
292+
// Only discussion notes
293+
if ($note->assocType !== PKPApplication::ASSOC_TYPE_QUERY) {
294+
return;
295+
}
296+
297+
// skip headnote initial email handled when query is created
298+
if ($note->isHeadnote ?? false) {
299+
return;
300+
}
301+
302+
$task = EditorialTask::find($note->assocId);
303+
if (!$task) {
304+
return;
305+
}
306+
307+
$submission = Repo::submission()->get($task->assocId);
308+
if (!$submission) {
309+
return;
310+
}
311+
312+
$application = Application::get();
313+
$request = $application->getRequest();
314+
$context = $request->getContext();
315+
if (!$context) {
316+
return;
317+
}
318+
319+
$sender = Repo::user()->get($note->userId ?? null);
320+
if (!$sender) {
321+
return;
322+
}
323+
324+
$headNote = Repo::note()->getHeadNote($task->id);
325+
$threadAnchorMessageId = $headNote?->messageId;
326+
$title = $headNote?->title ?: $task->title;
327+
$subject = $title
328+
? __('common.re') . ' ' . $title
329+
: __('common.re');
330+
331+
332+
$participantIds = $task->participants()
333+
->pluck('user_id')
334+
->all();
335+
336+
if (empty($participantIds)) {
337+
return;
338+
}
339+
340+
/** @var NotificationSubscriptionSettingsDAO $notificationSubscriptionSettingsDao */
341+
$notificationSubscriptionSettingsDao = DAORegistry::getDAO('NotificationSubscriptionSettingsDAO');
342+
343+
$notificationManager = new NotificationManager();
344+
345+
// attachments for this note (if any)
346+
$submissionFiles = Repo::submissionFile()
347+
->getCollector()
348+
->filterByAssoc(PKPApplication::ASSOC_TYPE_NOTE, [$note->id])
349+
->filterBySubmissionIds([$submission->getId()])
350+
->getMany();
351+
352+
// Stage -> mailable map (same as StageMailable)
353+
$mailableMap = [
354+
WORKFLOW_STAGE_ID_SUBMISSION => DiscussionSubmission::class,
355+
WORKFLOW_STAGE_ID_INTERNAL_REVIEW => DiscussionReview::class,
356+
WORKFLOW_STAGE_ID_EXTERNAL_REVIEW => DiscussionReview::class,
357+
WORKFLOW_STAGE_ID_EDITING => DiscussionCopyediting::class,
358+
WORKFLOW_STAGE_ID_PRODUCTION => DiscussionProduction::class,
359+
];
360+
361+
if (!isset($mailableMap[$task->stageId])) {
362+
return;
363+
}
364+
365+
$mailableClass = $mailableMap[$task->stageId];
366+
367+
foreach ($participantIds as $userId) {
368+
if ($userId === $sender->getId()) {
369+
continue;
370+
}
371+
372+
// clear previous "query activity" notifications for this user/query
373+
Notification::withAssoc(PKPApplication::ASSOC_TYPE_QUERY, $task->id)
374+
->withUserId($userId)
375+
->withType(Notification::NOTIFICATION_TYPE_QUERY_ACTIVITY)
376+
->withContextId((int) $context->getId())
377+
->delete();
378+
379+
$recipient = Repo::user()->get($userId);
380+
if (!$recipient) {
381+
continue;
382+
}
383+
384+
// create notification
385+
$notification = $notificationManager->createNotification(
386+
$userId,
387+
Notification::NOTIFICATION_TYPE_QUERY_ACTIVITY,
388+
(int) $context->getId(),
389+
PKPApplication::ASSOC_TYPE_QUERY,
390+
$task->id,
391+
Notification::NOTIFICATION_LEVEL_TASK
392+
);
393+
394+
if (!$notification) {
395+
continue;
396+
}
397+
398+
// respect email notification settings
399+
$blocked = $notificationSubscriptionSettingsDao->getNotificationSubscriptionSettings(
400+
NotificationSubscriptionSettingsDAO::BLOCKED_EMAIL_NOTIFICATION_KEY,
401+
$userId,
402+
(int) $context->getId()
403+
);
404+
405+
if (in_array(Notification::NOTIFICATION_TYPE_QUERY_ACTIVITY, $blocked)) {
406+
continue;
407+
}
408+
409+
/** @var \PKP\mail\Mailable $mailable */
410+
$mailable = new $mailableClass($context, $submission);
411+
412+
$mailable
413+
->sender($sender)
414+
->recipients([$recipient])
415+
->subject($subject)
416+
->body($note->contents)
417+
->allowUnsubscribe($notification)
418+
->allowCapturableReply(
419+
$note->messageId,
420+
$threadAnchorMessageId && $threadAnchorMessageId !== $note->messageId ? $threadAnchorMessageId : null,
421+
$threadAnchorMessageId ? [$threadAnchorMessageId] : []
422+
);
423+
424+
$submissionFiles->each(
425+
fn ($item) => $mailable->attachSubmissionFile(
426+
$item->getId(),
427+
$item->getData('name')
428+
)
429+
);
430+
431+
Mail::send($mailable);
432+
}
433+
}
434+
435+
253436
public function removeParticipantFromSubmissionTasks(int $submissionId, int $userId, int $contextId): void
254437
{
255438
$user = Repo::user()->get($userId);
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)