diff --git a/assets/src/bundles/add_forge/add-request-history-item.ejs b/assets/src/bundles/add_forge/add-request-history-item.ejs --- a/assets/src/bundles/add_forge/add-request-history-item.ejs +++ b/assets/src/bundles/add_forge/add-request-history-item.ejs @@ -6,9 +6,9 @@ %>
-
+

-

-
+
-

<%= event.text %>

+
<%= event.text %>
<%if (event.message_source_url !== null) { %>

Open original message in email client

<% } %> diff --git a/assets/src/bundles/add_forge/request-dashboard.js b/assets/src/bundles/add_forge/request-dashboard.js --- a/assets/src/bundles/add_forge/request-dashboard.js +++ b/assets/src/bundles/add_forge/request-dashboard.js @@ -57,13 +57,19 @@ // Setting data for the email, now adding static data $('#contactForgeAdmin').attr('emailTo', forgeRequest.forge_contact_email); + $('#contactForgeAdmin').attr('emailCc', forgeRequest.inbound_email_address); $('#contactForgeAdmin').attr('emailSubject', `Software Heritage archival request for ${forgeRequest.forge_domain}`); populateRequestHistory(data.history); populateDecisionSelectOption(forgeRequest.status); - } catch (response) { - // The error message - $('#fetchError').removeClass('d-none'); - $('#requestDetails').addClass('d-none'); + } catch (e) { + if (e instanceof Response) { + // The fetch request failed (in handleFetchError), show the error message + $('#fetchError').removeClass('d-none'); + $('#requestDetails').addClass('d-none'); + } else { + // Unknown exception, pass it through + throw e; + } } } @@ -123,9 +129,10 @@ function contactForgeAdmin(event) { // Open the mailclient with pre-filled text const mailTo = $('#contactForgeAdmin').attr('emailTo'); + const mailCc = $('#contactForgeAdmin').attr('emailCc'); const subject = $('#contactForgeAdmin').attr('emailSubject'); const emailText = emailTempate({'forgeUrl': forgeRequest.forge_url}).trim().replace(/\n/g, '%0D%0A'); const w = window.open('', '_blank', '', true); - w.location.href = `mailto: ${mailTo}?subject=${subject}&body=${emailText}`; + w.location.href = `mailto:${mailTo}?Cc=${mailCc}&Reply-To=${mailCc}&Subject=${subject}&body=${emailText}`; w.focus(); } diff --git a/cypress/integration/add-forge-now-request-dashboard.spec.js b/cypress/integration/add-forge-now-request-dashboard.spec.js --- a/cypress/integration/add-forge-now-request-dashboard.spec.js +++ b/cypress/integration/add-forge-now-request-dashboard.spec.js @@ -5,31 +5,36 @@ * See top-level LICENSE file for more information */ -let requestId, forgeDomain; +let requestId; +let requestForgeDomain; +let requestInboundEmailAddress; + +const requestData = { + forge_type: 'bitbucket', + forge_url: 'test.example.com', + forge_contact_email: 'test@example.com', + forge_contact_name: 'test user', + submitter_forward_username: true, + forge_contact_comment: 'test comment' +}; function createDummyRequest(urls) { cy.task('db:add_forge_now:delete'); cy.userLogin(); - cy.getCookie('csrftoken').its('value').then((token) => { + return cy.getCookie('csrftoken').its('value').then((token) => { cy.request({ method: 'POST', url: urls.api_1_add_forge_request_create(), - body: { - forge_type: 'bitbucket', - forge_url: 'test.example.com', - forge_contact_email: 'test@example.com', - forge_contact_name: 'test user', - submitter_forward_username: true, - forge_contact_comment: 'test comment' - }, + body: requestData, headers: { 'X-CSRFToken': token } }).then((response) => { // setting requestId and forgeDomain from response requestId = response.body.id; - forgeDomain = response.body.forge_domain; + requestForgeDomain = response.body.forge_domain; + requestInboundEmailAddress = response.body.inbound_email_address; // logout the user cy.visit(urls.swh_web_homepage()); cy.contains('a', 'logout').click(); @@ -37,6 +42,46 @@ }); } +function genEmailSrc() { + return `Message-ID: +Date: Tue, 19 Apr 2022 14:00:56 +0200 +MIME-Version: 1.0 +User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 + Thunderbird/91.8.0 +To: ${requestData.forge_contact_email} +Cc: ${requestInboundEmailAddress} +Reply-To: ${requestInboundEmailAddress} +Subject: Software Heritage archival request for test.example.com +Content-Language: en-US +From: Test Admin +Content-Type: text/plain; charset=UTF-8; format=flowed +Content-Transfer-Encoding: 7bit + +Dear forge administrator, + +The mission of Software Heritage is to collect, preserve and share all the +publicly available source code (see https://www.softwareheritage.org for more +information). + +We just received a request to add the forge hosted at https://test.example.com to the +list of software origins that are archived, and it is our understanding that you +are the contact person for this forge. + +In order to archive the forge contents, we will have to periodically pull the +public repositories it contains and clone them into the +Software Heritage archive. + +Would you be so kind as to reply to this message to acknowledge the reception +of this email and let us know if there are any special steps we should take in +order to properly archive the public repositories hosted on your infrastructure? + +Thank you in advance for your help. + +Kind regards, +The Software Heritage team +`; +} + describe('Test add forge now request dashboard load', function() { before(function() { @@ -88,7 +133,7 @@ cy.get('#contactForgeAdmin') .should('have.attr', 'emailsubject') - .and('include', `Software Heritage archival request for ${forgeDomain}`); + .and('include', `Software Heritage archival request for ${requestForgeDomain}`); }); it('should not show any error message', function() { @@ -146,22 +191,24 @@ cy.get('#updateRequestForm').submit(); } -describe('Test forge now request update', function() { +describe('Test add forge now request update', function() { beforeEach(function() { - createDummyRequest(this.Urls); - - const url = this.Urls.add_forge_now_request_dashboard(requestId); - cy.adminLogin(); - // intercept GET API on page load - cy.intercept(`${this.Urls.api_1_add_forge_request_get(requestId)}**`).as('forgeRequestGet'); - // intercept update POST API - cy.intercept('POST', `${this.Urls.api_1_add_forge_request_update(requestId)}**`).as('forgeRequestUpdate'); - cy.visit(url); + createDummyRequest(this.Urls).then(() => { + + this.url = this.Urls.add_forge_now_request_dashboard(requestId); + cy.adminLogin(); + // intercept GET API on page load + cy.intercept(`${this.Urls.api_1_add_forge_request_get(requestId)}**`).as('forgeRequestGet'); + // intercept update POST API + cy.intercept('POST', `${this.Urls.api_1_add_forge_request_update(requestId)}**`).as('forgeRequestUpdate'); + cy.visit(this.url).then(() => { + cy.wait('@forgeRequestGet'); + }); + }); }); it('should submit correct details', function() { - cy.wait('@forgeRequestGet'); populateAndSubmitForm(); // Making sure posting the right data @@ -172,7 +219,6 @@ }); it('should show success message', function() { - cy.wait('@forgeRequestGet'); populateAndSubmitForm(); // Making sure showing the success message @@ -184,7 +230,6 @@ }); it('should update the dashboard after submit', function() { - cy.wait('@forgeRequestGet'); populateAndSubmitForm(); // Making sure the UI is updated after the submit @@ -213,6 +258,39 @@ .should('have.length', 2); }); + it('should update the dashboard after receiving email', function() { + const emailSrc = genEmailSrc(); + cy.task('processAddForgeNowInboundEmail', emailSrc); + + // Refresh page and wait for the async request to complete + cy.visit(this.url); + cy.wait('@forgeRequestGet'); + + cy.get('#requestHistory') + .children() + .should('have.length', 2); + + cy.get('#historyItem1') + .click() + .should('contain', 'New status: Waiting for feedback'); + + cy.get('#historyItemBody1') + .find('a') + .should('contain', 'Open original message in email client') + .should('have.prop', 'href').and('contain', '/message-source/').then(function(href) { + cy.request(href).then((response) => { + expect(response.headers['content-type']) + .to.equal('text/email'); + + expect(response.headers['content-disposition']) + .to.match(/filename="add-forge-now-test.example.com-message\d+.eml"/); + + expect(response.body) + .to.equal(emailSrc); + }); + }); + }); + it('should show an error on API failure', function() { cy.intercept('POST', `${this.Urls.api_1_add_forge_request_update(requestId)}**`, diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js --- a/cypress/plugins/index.js +++ b/cypress/plugins/index.js @@ -6,6 +6,7 @@ */ const axios = require('axios'); +const {execFileSync} = require('child_process'); const fs = require('fs'); const sqlite3 = require('sqlite3').verbose(); @@ -159,6 +160,16 @@ }); db.close(); return true; + }, + processAddForgeNowInboundEmail(emailSrc) { + try { + execFileSync('django-admin', + ['process_inbound_email', '--settings=swh.web.settings.tests'], + {input: emailSrc}); + return true; + } catch (_) { + return false; + } } }); return config; diff --git a/swh/web/add_forge_now/apps.py b/swh/web/add_forge_now/apps.py --- a/swh/web/add_forge_now/apps.py +++ b/swh/web/add_forge_now/apps.py @@ -11,3 +11,9 @@ class AddForgeNowConfig(AppConfig): name = "swh.web.add_forge_now" label = APP_LABEL + + def ready(self): + from ..inbound_email.signals import email_received + from .signal_receivers import handle_inbound_message + + email_received.connect(handle_inbound_message) diff --git a/swh/web/add_forge_now/signal_receivers.py b/swh/web/add_forge_now/signal_receivers.py new file mode 100644 --- /dev/null +++ b/swh/web/add_forge_now/signal_receivers.py @@ -0,0 +1,77 @@ +# Copyright (C) 2022 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from email.message import EmailMessage +from typing import Type + +from ..config import get_config +from ..inbound_email.signals import EmailProcessingStatus +from ..inbound_email.utils import get_message_plaintext, get_pks_from_message +from .apps import APP_LABEL +from .models import Request, RequestActorRole, RequestHistory, RequestStatus + + +def handle_inbound_message(sender: Type, **kwargs) -> EmailProcessingStatus: + """Handle inbound email messages for add forge now. + + This uses the from field in the message to set the actor for the new entry in the + request history. We also unconditionally advance the status of requests in the + ``PENDING`` or ``WAITING_FOR_FEEDBACK`` states. + + The message source is saved in the request history as an escape hatch if something + goes wrong during processing. + + """ + + message = kwargs["message"] + assert isinstance(message, EmailMessage) + + base_address = get_config()["add_forge_now"]["email_address"] + + pks = get_pks_from_message( + salt=APP_LABEL, base_address=base_address, message=message + ) + + if not pks: + return EmailProcessingStatus.IGNORED + + for pk in pks: + try: + request = Request.objects.get(pk=pk) + except Request.DoesNotExist: + continue + + request_updated = False + + message_plaintext = get_message_plaintext(message) + if message_plaintext: + history_text = message_plaintext.decode("utf-8", errors="replace") + else: + history_text = ( + "Could not parse the message contents, see the original message." + ) + + history_entry = RequestHistory( + request=request, + actor=str(message["from"]), + actor_role=RequestActorRole.EMAIL.name, + text=history_text, + message_source=bytes(message), + ) + + new_status = { + RequestStatus.PENDING: RequestStatus.WAITING_FOR_FEEDBACK, + RequestStatus.WAITING_FOR_FEEDBACK: RequestStatus.FEEDBACK_TO_HANDLE, + }.get(RequestStatus[request.status]) + + if new_status: + request.status = history_entry.new_status = new_status.name + request_updated = True + + history_entry.save() + if request_updated: + request.save() + + return EmailProcessingStatus.PROCESSED diff --git a/swh/web/templates/add_forge_now/request-dashboard.html b/swh/web/templates/add_forge_now/request-dashboard.html --- a/swh/web/templates/add_forge_now/request-dashboard.html +++ b/swh/web/templates/add_forge_now/request-dashboard.html @@ -107,7 +107,7 @@