From c8b44237336890a893b3f8c530029c9f8b7fadd3 Mon Sep 17 00:00:00 2001 From: David Upton Date: Fri, 19 Apr 2024 09:44:41 -0400 Subject: [PATCH 01/48] DIG-4317 Implements sanitation email functionality --- .../modules/bos_email/bos_email.http | 895 ++++++++++++++++++ .../bos_email/bos_email.links.menu.yml | 6 + .../bos_email/bos_email.links.task.yml | 5 + .../modules/bos_email/bos_email.module | 206 ---- .../modules/bos_email/bos_email.routing.yml | 8 + .../modules/bos_email/http-client.env.json | 9 + .../modules/bos_email/src/CobEmail.php | 8 +- .../src/Controller/DrupalmailAPI.php | 4 +- .../bos_email/src/Controller/PostmarkAPI.php | 54 +- .../bos_email/src/Controller/PostmarkOps.php | 8 +- .../bos_email/src/EmailTemplateInterface.php | 4 +- .../modules/bos_email/src/Form/ConfigForm.php | 267 ++++++ .../QueueWorker/ContactformProcessItems.php | 11 +- .../QueueWorker/ScheduledEmailProcessor.php | 60 ++ .../bos_email/src/Templates/Sanitation.php | 100 ++ 15 files changed, 1420 insertions(+), 225 deletions(-) create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/bos_email.http create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/bos_email.links.menu.yml create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/bos_email.links.task.yml create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/http-client.env.json create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Form/ConfigForm.php create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http new file mode 100644 index 0000000000..aee952d5e7 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http @@ -0,0 +1,895 @@ +### +# group: bos_email / session token +# @name Request a client-side token +# This is **step 1** of a 2 step process to submit the contact form to Postmark for relay to a CoB email recipient. +# This step posts an empty payload to the endpoint and receives back a token which is saved on the form and then used for submit form action (step 2). + +POST {{url}}/rest/email_token/create +Authorization: Token {{bos_email_bearer_token}} + +> {% + client.test("Status code is 200", function () { + client.assert(response.status == 200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Response contains 'token_session'", function () { + client.assert(typeof response.body.token_session !== "undefined", "Response body is not 'token_session'"); + }); + client.global.set("email_session_token", response.body.token_session); +%} + +### +# group: bos_email / session token +# @name Use client-side token for Contact Form +POST {{url}}/rest/email_session/contactform +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/x-www-form-urlencoded + +email[token_session] = {{email_session_token}} & +email[to_address] = {{email_test_recipient}} & +email[name] = Boston Resident & +email[from_address] = boston_resident_1921@gmail.com & +email[subject] = Test Contact Form Submission & +email[message] = Manual test contact form submission from Postman. & +email[url] = https://www.boston.gov/somepage & +email[browser] = PostmanRuntime/7.29.2 & +email[contact] = + +> {% + client.global.clear("email_session_token"); + client.test("Status code is 200", function () { + client.assert(response.status == 200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Response status reports 'Success'", function () { + client.assert(response.body.status == "success", "Response body.status is not 'success'"); + }); + client.test("Response message is 'Message Sent'", function () { + client.assert(response.body.response == "Message sent", "Response message is not 'Message sent'"); + }); +%} + +### +# group: bos_email / sanitation +# @name Sanitation on-demand: Bearer Token +POST {{url}}/rest/email/sanitation +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/json +Authorization: Token {{bos_email_bearer_token}} + +{ + "to_address": "{{email_test_recipient}}", + "from_address": "Sanitation ", + "subject": "Sanitation Confirmation", + "message": "We are pleased to confirm your pickup", + "type": "confirmation" +} + +> {% + client.test("Status code is 200", function () { + client.assert(response.status ==200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "success", "Response status field is not 'success'"); + client.assert(response.body.response == "Message sent" || response.body.response == "Message queued", "Response message is not expected"); + }); +%} + +### +# group: bos_email / sanitation +# @name Sanitation scheduled: Bearer Token +POST {{url}}/rest/email/sanitation +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/json +Authorization: Token {{bos_email_bearer_token}} + +{ + "to_address": "{{email_test_recipient}}", + "from_address": "Sanitation ", + "subject": "Sanitation Confirmation", + "message": "We are pleased to confirm your pickup", + "type": "reminder1", + "senddatetime": "10/02/2024 15:00" +} + +> {% + client.test("Status code is 200", function () { + client.assert(response.status ==200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "success", "Response status field is not 'success'"); + client.assert(response.body.response == "Message scheduled", "Response message is not 'Message scheduled'"); + }); + client.test("Response contains email id", function () { + client.assert(typeof response.body.id !== "undefined" && response.body.id != "", "Response does not contain an ID field"); + }); +%} + +### +# group: bos_email / contact form +# @name Contact Form: Bearer Token +POST {{url}}/rest/email/contactform +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: multipart/form-data; boundary=WebAppBoundary +Authorization: Token {{bos_email_bearer_token}} + +--WebAppBoundary +Content-Disposition: form-data; name="email[name]" + +Boston Resident +--WebAppBoundary +Content-Disposition: form-data; name="email[from_address]" + +boston_resident_1921@gmail.com +--WebAppBoundary +Content-Disposition: form-data; name="email[url]" + +https://www.boston.gov/somepage +--WebAppBoundary +Content-Disposition: form-data; name="email[to_address]" + +{{email_test_recipient}} +--WebAppBoundary +Content-Disposition: form-data; name="email[message]" + +Why are the road ways in the city a boring grey? +We should embrace our heritage and make them all green. If we can't get agreement from everyone to do this, maybe we could just do it for St Patricks day. +Thanks +Paddy McTavish +--WebAppBoundary +Content-Disposition: form-data; name="email[subject]" + +Question about roadways. +--WebAppBoundary +Content-Disposition: form-data; name="email[useHtml]" + +0 +--WebAppBoundary +Content-Disposition: form-data; name="emai[contact]" + + +--WebAppBoundary + +> {% + // TODO: migrate to HTTP Client Response handler API + // pm.test("Status code is 200", function () { + // pm.response.to.have.status(200); + // }); + // pm.test("Status is 'Success'", function () { + // pm.expect(pm.response.text()).to.include("success"); + // }); + // pm.test("Response is 'Message Sent'", function () { + // pm.expect(pm.response.text()).to.include("Message sent"); + // }); +%} + +### +# group: bos_email / contact form +# @name Contact Form HTML: Bearer Token + +POST {{url}}/rest/email/contactform +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/x-www-form-urlencoded +Authorization: Token {{bos_email_bearer_token}} + +email[useHtml] = 1 & +email[to_address] = {{email_test_recipient}} & +email[name] = Boston Resident & +email[from_address] = boston_resident_1921@gmail.com & +email[subject] = I have a great idea. & +email[message] = Why are the road ways in the city a boring grey? +We should embrace our heritage and make them all green. If we can't get agreement from everyone to do this, maybe we could just do it for St Patricks day. +Thanks +Paddy McTavish & +email[url] = https://www.boston.gov/somepage & +email[browser] = PostmanRuntime/7.29.2 & +email[contact] = + +> {% + // TODO: migrate to HTTP Client Response handler API + // pm.test("Status code is 200", function () { + // pm.response.to.have.status(200); + // }); + // pm.test("Status is 'Success'", function () { + // pm.expect(pm.response.text()).to.include("success"); + // }); + // pm.test("Response is 'Message Sent'", function () { + // pm.expect(pm.response.text()).to.include("Message sent"); + // }); +%} + +### +# group: bos_email / registry +# @name Registry: Bearer Token +POST {{url}}/rest/email/registry +Content-Type: application/x-www-form-urlencoded +Authorization: Token {{bos_email_bearer_token}} + +email[to_address] = {{email_test_recipient}} & +email[from_address] = marriage@boston.gov & +email[subject] = TEST Marriage Intention Application & +email[message] = Thank you for submitting your marriage intention application. Have questions? Contact us at 0617-635-4175 or marriage@boston.gov & +email[sender] = City of Boston Registry & +email[template_id] = {{registry_template_id}} & +email[name] = Boston Resident + +> {% + // TODO: migrate to HTTP Client Response handler API + // pm.test("Status code is 200", function () { + // pm.response.to.have.status(200); + // }); + // pm.test("Status is 'Success'", function () { + // pm.expect(pm.response.text()).to.include("success"); + // }); + // pm.test("Response is 'Message Sent'", function () { + // pm.expect(pm.response.text()).to.include("Message sent"); + // }); +%} + +### +# group: bos_email / metrolist +# @name Metrolist Initiation (form-data): Bearer Token +POST {{url}}/rest/email/MetrolistInitiationForm +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: multipart/form-data; boundary=WebAppBoundary +Authorization: Token {{bos_email_bearer_token}} + +--WebAppBoundary +Content-Disposition: form-data; name="email[to_address]" + +{{email_test_recipient}} +--WebAppBoundary +Content-Disposition: form-data; name="email[name]" + +David Upton +--WebAppBoundary +Content-Disposition: form-data; name="email[from_address]" + +digital-dev@boston.gov +--WebAppBoundary +Content-Disposition: form-data; name="email[url]" + +boston.gov/here +--WebAppBoundary +Content-Disposition: form-data; name="email[message]" + +{{url}}/form/metrolist-listing?token=abctesttoken +--WebAppBoundary +Content-Disposition: form-data; name="email[subject]" + +TEST Metrolist Initiation Email +--WebAppBoundary +Content-Disposition: form-data; name="email[useHtml]" + +1 +--WebAppBoundary + +> {% + // TODO: migrate to HTTP Client Response handler API + // pm.test("Status code is 200", function () { + // pm.response.to.have.status(200); + // }); + // pm.test("Status is 'Success'", function () { + // pm.expect(pm.response.text()).to.include("success"); + // }); + // pm.test("Response is 'Message Sent'", function () { + // pm.expect(pm.response.text()).to.include("Message sent"); + // }); +%} + +### +# group: bos_email / metrolist +# @name Metrolist Initiation (json): Bearer Token +POST {{url}}/rest/email/MetrolistInitiationForm +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/json +Authorization: Token {{bos_email_bearer_token}} + +{ + "email[name]": "MetroList Listing", + "email[url]": "https:\/\/boston.lndo.site\/metrolist\/listing-request", + "email[from_address]": "noreply@boston.gov", + "email[sender]": "Metrolist Listing", + "email[to_address]": "david.upton@boston.gov", + "email[message]": "https:\/\/boston.lndo.site\/form\/metrolist-listing?token=ZeNCdhEQXryMmZXNzqKCrVpcXdKWsmaC4gNMF1tZzVo", + "email[useHtml]": "1", + "email[subject]": "Your requested MetroList Listing link", + "hidden_name": "MetroList Listing", + "hidden_subject": "Your requested MetroList Listing link", + "hidden_message": "https:\/\/boston.lndo.site\/form\/metrolist-listing?token=ZeNCdhEQXryMmZXNzqKCrVpcXdKWsmaC4gNMF1tZzVo", + "token": "qDfBKuYCJud_msnfiQHDC1yx0h0gqMeyyT5AzOjg0_w" +} + +> {% + // TODO: migrate to HTTP Client Response handler API + // pm.test("Status code is 200", function () { + // pm.response.to.have.status(200); + // }); + // pm.test("Status is 'Success'", function () { + // pm.expect(pm.response.text()).to.include("success"); + // }); + // pm.test("Response is 'Message Sent'", function () { + // pm.expect(pm.response.text()).to.include("Message sent"); + // }); +%} + +### +# group: bos_email / metrolist +# @name Metrolist Confirmation (form-data): Bearer Token +POST {{url}}/rest/email/MetrolistListingConfirmation +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: multipart/form-data; boundary=WebAppBoundary +Authorization: Token {{bos_email_bearer_token}} + +--WebAppBoundary +Content-Disposition: form-data; name="email[to_address]" + +{{email_test_recipient}} +--WebAppBoundary +Content-Disposition: form-data; name="email[name]" + +David Upton +--WebAppBoundary +Content-Disposition: form-data; name="email[from_address]" + +digital-dev@boston.gov +--WebAppBoundary +Content-Disposition: form-data; name="email[url]" + +boston.gov/here +--WebAppBoundary +Content-Disposition: form-data; name="email[message]" + +https://boston.lndo.site/form/metrolist-listing?token=abctesttoken +--WebAppBoundary +Content-Disposition: form-data; name="email[subject]" + +TEST Metrolist Confirmation Email +--WebAppBoundary +Content-Disposition: form-data; name="email[useHtml]" + +1 +--WebAppBoundary +Content-Disposition: form-data; name="property_name" + +Davids Property +--WebAppBoundary + +> {% + // TODO: migrate to HTTP Client Response handler API + // pm.test("Status code is 200", function () { + // pm.response.to.have.status(200); + // }); + // pm.test("Status is 'Success'", function () { + // pm.expect(pm.response.text()).to.include("success"); + // }); + // pm.test("Response is 'Message Sent'", function () { + // pm.expect(pm.response.text()).to.include("Message sent"); + // }); +%} + +### +# group: bos_email / metrolist +# @name Metrolist Notification (form-data): Bearer Token +POST {{url}}/rest/email/MetrolistListingNotification +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: multipart/form-data; boundary=WebAppBoundary +Authorization: Token {{bos_email_bearer_token}} + +--WebAppBoundary +Content-Disposition: form-data; name="email[name]" + +David Upton +--WebAppBoundary +Content-Disposition: form-data; name="email[url]" + +boston.gov/here +--WebAppBoundary +Content-Disposition: form-data; name="email[sender]" + +Metrolist Listing +--WebAppBoundary +Content-Disposition: form-data; name="email[to_address]" + +{{email_test_recipient}} +--WebAppBoundary +Content-Disposition: form-data; name="email[message]" + +https://boston.lndo.site/form/metrolist-listing?token=abctesttoken +--WebAppBoundary +Content-Disposition: form-data; name="email[useHtml]" + +1 +--WebAppBoundary +Content-Disposition: form-data; name="email[from_address]" + +digital-dev@boston.gov +--WebAppBoundary +Content-Disposition: form-data; name="email[subject]" + +TEST Metrolist Notification Email +--WebAppBoundary +Content-Disposition: form-data; name="property_name" + +Davids Property +--WebAppBoundary +Content-Disposition: form-data; name="new" + +1 +--WebAppBoundary + +> {% + // TODO: migrate to HTTP Client Response handler API + // pm.test("Status code is 200", function () { + // pm.response.to.have.status(200); + // }); + // pm.test("Status is 'Success'", function () { + // pm.expect(pm.response.text()).to.include("success"); + // }); + // pm.test("Response is 'Message Sent'", function () { + // pm.expect(pm.response.text()).to.include("Message sent"); + // }); +%} + + +### +# group: bos_email / inbound +# @name Incoming Webhook (POSTMARK) +POST {{url}}/rest/email/postmark/Contactform/inbound +Content-Type: application/json + +{ + "FromName": "Info", + "MessageStream": "inbound", + "From": "Info@bphc.org", + "FromFull": { + "Email": "Info@bphc.org", + "Name": "Info", + "MailboxHash": "" + }, + "To": "\"Boston.gov Contact Form\" ", + "ToFull": [ + { + "Email": "ZGF2aWQudXB0b25AYm9zdG9uLmdvdg==@web-inbound.boston.gov", + "Name": "Boston.gov Contact Form", + "MailboxHash": "" + } + ], + "Cc": "", + "CcFull": [], + "Bcc": "", + "BccFull": [], + "OriginalRecipient": "ZGF2aWQudXB0b25AYm9zdG9uLmdvdg==@web-inbound.boston.gov", + "Subject": "Automatic reply: Proper disposal of pathological liquids", + "MessageID": "86d5de3d-1672-414b-8d58-291d7edb735f", + "ReplyTo": "", + "MailboxHash": "", + "Date": "Thu, 11 May 2023 20:35:35 +0000", + "TextBody": "Thank you for contacting the Boston Public Health Commission (BPHC). We received your email and BPHC staff will respond to your message within three business days. If your email requires a faster response, Boston residents can call:\n\n· BPHC Main Line: (617) 534-5395 (Monday through Friday, 9am to 5pm)\n· Mayor's Hotline: (617) 635-4500 (after 5pm, during the weekends)\n\nDuring the current COVID-19 public health crisis, BPHC continues to protect, preserve, and promote the health and well-being of all Boston residents, particularly the most vulnerable. If you are a Boston resident with questions about COVID-19, you can find the most current information about COVID-19, testing, vaccines and boosters, and best health practices at our website, www.boston.gov/covid19\n\nTo find information about COVID-19 testing sites in Boston, please visit our COVID-19 Testing Site Page: www.boston.gov/covid19-testing\n\nTo find information about COVID-19 vaccine and booster sites in Boston, please visit our COVID-19 Vaccine Page: www.boston.gov/covid19-vaccines\n\nIf you are seeking a replacement COVID-19 vaccine card, you will need to reach out to the vaccine administer directly for a copy of your records. CIC Health administered vaccines at the Reggie Lewis Center Vaccine Site. You may contact them directly with vaccine questions at (888) 623-3830 or by emailing vaccine-support@cic-health.com\n\nFor those living outside Boston please contact the Massachusetts Department of Public Health at (617) 624-6000 or visit their website, www.mass.gov/covid19.\n\nPlease use the following additional resources for assistance:\n\n\n1) Boston residents call Mayor's Health Line: 617-534-5050/ Toll free: 1-800-847-0710 for information about finding a primary care provider; applying for health insurance; food pantries; Boston Public Health School lunch sites; COVID symptoms; COVID19 cleaning practices; when to call your doctor v. emergency room; donating medical supplies; and other related information.\n\n2) Boston residents call \"311\" for information about parking rules and tickets; needle/litter clean up; street cleaning; getting rid of a big item; report a broken street sign; and non-emergency COVID-related issues.\n\n3) Massachusetts residents call \"211\" for information about testing sites; COVID19 symptoms; Latest state-wide orders; benefit programs (SNAP, unemployment), Call2Talk - over the phone behavioral health services/support; and other related information.\n", + "HtmlBody": "\n\n\n\n\nThank you for contacting the Boston Public Health Commission (BPHC). We received your email and BPHC staff will respond to your message within three business days. If your email requires a faster response, Boston residents can call:
\n
\n· BPHC Main Line: (617) 534-5395 (Monday through Friday, 9am to 5pm)
\n· Mayor's Hotline: (617) 635-4500 (after 5pm, during the weekends)
\n
\nDuring the current COVID-19 public health crisis, BPHC continues to protect, preserve, and promote the health and well-being of all Boston residents, particularly the most vulnerable. If you are a Boston resident with questions about COVID-19, you can find\n the most current information about COVID-19, testing, vaccines and boosters, and best health practices at our website, www.boston.gov/covid19  
\n
\nTo find information about COVID-19 testing sites in Boston, please visit our COVID-19 Testing Site Page: www.boston.gov/covid19-testing
\n
\nTo find information about COVID-19 vaccine and booster sites in Boston, please visit our COVID-19 Vaccine Page: www.boston.gov/covid19-vaccines
\n
\nIf you are seeking a replacement COVID-19 vaccine card, you will need to reach out to the vaccine administer directly for a copy of your records. CIC Health administered vaccines at the Reggie Lewis Center Vaccine Site. You may contact them directly with vaccine\n questions at (888) 623-3830 or by emailing vaccine-support@cic-health.com
\n
\nFor those living outside Boston please contact the Massachusetts Department of Public Health at (617) 624-6000 or visit their website, www.mass.gov/covid19.
\n
\nPlease use the following additional resources for assistance:
\n
\n
\n1) Boston residents call Mayor's Health Line: 617-534-5050/ Toll free: 1-800-847-0710 for information about finding a primary care provider; applying for health insurance; food pantries; Boston Public Health School lunch sites; COVID symptoms; COVID19 cleaning\n practices; when to call your doctor v. emergency room; donating medical supplies; and other related information.
\n
\n2) Boston residents call "311" for information about parking rules and tickets; needle/litter clean up; street cleaning; getting rid of a big item; report a broken street sign; and non-emergency COVID-related issues.
\n
\n3) Massachusetts residents call "211" for information about testing sites; COVID19 symptoms; Latest state-wide orders; benefit programs (SNAP, unemployment), Call2Talk - over the phone behavioral health services/support; and other related information.\n\n\n", + "StrippedTextReply": "", + "RawEmail": "Received: by p-pm-inboundg02c-aws-useast1c.inbound.postmarkapp.com (Postfix, from userid 996)\n\tid 9B88B453CA3; Thu, 11 May 2023 20:35:38 +0000 (UTC)\nX-Spam-Checker-Version: SpamAssassin 3.4.0 (2014-02-07) on\n\tp-pm-inboundg02c-aws-useast1c\nX-Spam-Status: No\nX-Spam-Score: 1.9\nX-Spam-Tests: DKIM_SIGNED,DKIM_VALID,DKIM_VALID_AU,FORGED_SPF_HELO,\n\tHTML_MESSAGE,PDS_BAD_THREAD_QP_64,RCVD_IN_DNSWL_NONE,\n\tRCVD_IN_ZEN_BLOCKED_OPENDNS,SPF_HELO_PASS,T_SCC_BODY_TEXT_LINE,\n\tURIBL_DBL_BLOCKED_OPENDNS,URIBL_ZEN_BLOCKED_OPENDNS\nReceived-SPF: pass (gcc02-bl0-obe.outbound.protection.outlook.com: Sender is authorized to use 'GCC02-BL0-obe.outbound.protection.outlook.com' in 'helo' identity (mechanism 'include:spf.protection.outlook.com' matched)) receiver=p-pm-inboundg02c-aws-useast1c; identity=helo; helo=GCC02-BL0-obe.outbound.protection.outlook.com; client-ip=52.100.153.248\nReceived: from GCC02-BL0-obe.outbound.protection.outlook.com (mail-bl0gcc02hn2248.outbound.protection.outlook.com [52.100.153.248])\n\t(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))\n\t(No client certificate requested)\n\tby p-pm-inboundg02c-aws-useast1c.inbound.postmarkapp.com (Postfix) with ESMTPS id 18A04453CA2\n\tfor ; Thu, 11 May 2023 20:35:38 +0000 (UTC)\nARC-Seal: i=1; a=rsa-sha256; s=arcselector9901; d=microsoft.com; cv=none;\n b=jj6N9vbdzMkbk8C9u65DF2AFH42m50PODQZzrTWWtjRlzLcxXbYHC8Kp3nBHn5EG9/zxEgNRvRQyY4bbs6ClL0cJ205BwlUVi5asrB/mClpfAryWjcYx63xKL6/lleHujD1lv1AP+v8uZTJjxLun16JrQpQn/0duv9s/MORtiTHVYWBXeRWjfvIz185ady0SySOq/+OOJNTUxAkxsa3Mc7dEB6eN5HuPyHfJ+OKp1ZKkMrsIIs4gsnw51XaRkjSaMPmSOwQQJTNTzupt6JwLG9rLSOEBmq9jIJx+BcyVW9zYDzhcfsbanUN+eNizkNz+Z/CBwTT7SS7O9wc2eOBJJQ==\nARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com;\n s=arcselector9901;\n h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-AntiSpam-MessageData-ChunkCount:X-MS-Exchange-AntiSpam-MessageData-0:X-MS-Exchange-AntiSpam-MessageData-1;\n bh=KA6k2PkR+L1ueLPEaPoMLGed3YXFrrljNLt+SeQ68gI=;\n b=bTV/qXKJCSV7D8HmtUX2UcxVdAKsJWkyzN3BdwwJQ2S7PgZSlZr3K0Bz0/ELP3P0CUvjXhpjN30I5Td1dhh1QnK9zKtSg7ZDjtEdRoOVBfwutnnM0WdTGl/e1P93WNiUfHfQoLgeUGlwm7fBRPxD005vF3LeV6BgMOxpxy2Bb8PhEeXExw6Xj/jqHgPRsTOBK3QokgzsMT3skT4hed019xME3jqz0Sb9a/n1yGkpOCgijDbAqiaVTpg39z3GlD3iBzWrziT4kEsy0BjrQXclbqJmJkPZJZRuKnLykhY8Qerdq3NbbyVK4swi0euIQO1lDV+bDdwVHNvoB5PQyp2aqQ==\nARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=none; dmarc=pass\n action=none header.from=bphc.org; dkim=pass header.d=bphc.org; arc=none\nDKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=bphc.org; s=selector2;\n h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck;\n bh=KA6k2PkR+L1ueLPEaPoMLGed3YXFrrljNLt+SeQ68gI=;\n b=pY2DTo39+ER8bre34Y2S0lgE++js3gxKb8OBIHjFx2paOwkhJveEBJHJkxkWsrE6A8Uz5FH+ozHZFufhqtpNQwBLZ+mUK5l+YFg+dYlsA8MAM3ZRlbE0Om+hC+WgLYDBwekDBdMJwsJHGzCwyqiHj9qewp9IeqL1AuB586B/L7w=\nReceived: from DM8PR09MB7205.namprd09.prod.outlook.com (2603:10b6:5:2ea::17)\n by SA1PR09MB8987.namprd09.prod.outlook.com (2603:10b6:806:28d::16) with\n Microsoft SMTP Server (version=TLS1_2,\n cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.6387.22; Thu, 11 May\n 2023 20:35:35 +0000\nReceived: from DM8PR09MB7205.namprd09.prod.outlook.com ([::1]) by\n DM8PR09MB7205.namprd09.prod.outlook.com ([fe80::4c61:f919:1a19:e21a%4]) with\n Microsoft SMTP Server id 15.20.6387.020; Thu, 11 May 2023 20:35:35 +0000\nFrom: Info \nTo: Boston.gov Contact Form \nSubject: Automatic reply: Proper disposal of pathological liquids\nThread-Topic: Proper disposal of pathological liquids\nThread-Index: AQHZhEgj+jgKWSu0QUGFLyYcmlnYGa9Vh7ci\nDate: Thu, 11 May 2023 20:35:35 +0000\nMessage-ID:\n <2dca75707c624d9e9fa2ca731155aa34@DM8PR09MB7205.namprd09.prod.outlook.com>\nReferences: \nIn-Reply-To: \nX-MS-Has-Attach:\nX-Auto-Response-Suppress: All\nX-MS-Exchange-Inbox-Rules-Loop: info@bphc.org\nX-MS-TNEF-Correlator:\nauthentication-results: dkim=none (message not signed)\n header.d=none;dmarc=none action=none header.from=bphc.org;\nx-ms-exchange-parent-message-id:\n \nauto-submitted: auto-generated\nx-ms-exchange-generated-message-source: Mailbox Rules Agent\nx-ms-publictraffictype: Email\nx-ms-traffictypediagnostic: DM8PR09MB7205:EE_|SA1PR09MB8987:EE_\nx-ms-office365-filtering-correlation-id: febdd29a-c5c3-4f17-c55a-08db525f4636\nx-ms-exchange-senderadcheck: 1\nx-ms-exchange-antispam-relay: 0\nx-microsoft-antispam: BCL:0;\nx-microsoft-antispam-message-info:\n xAGG2I7C6b/RY4hW+qwrkIoXgwl1ofcFIeVK2ehyn/jQXkJHANV4/yhBBe7T5zX7lYfL5eknK0zN+tzvzVBxqibPBCvxaNsj+CLFNOp097SO3RaePW4Yt88AQM1/O9OSQFOEhEB7qSN35Zqy6VC7/ijKH8aCENL4u6KNayclw/6g31jgAIrHxpiGZZA/mvLy6NNpdsQOUjR85mEvsP1+BirOALWzsmSgSU1peSo0P9vWU3rUWkjMjqtlDzr7Tr2knzTXgq5mR+gDUQ87OHlZms/i4qC5A6Y1BlW5937NGi9Lh4N3wh+I94ak63BT8CwCfIO/XVpMy2AEJaJkholv3D4DzAMFn7y8rkaZi3i3xauUMAuJuFRx5mFjGTFSdCkd8YypUotYToXFGgRpxPjwJFN7swABHgr1t2xhO4JR1LSIE9apqheptYzUZZhv8P77D9WTBozD1aohHZN2S4GY3iwHSPuVrX1pXeXU7Oe+9pb3iHBbfUCQYzn64Hi0gzfK+SMWCPHrJTL6+TCNiR6FkcfwZV0v7TKZDaqrnMUJm1x7yiCWwHHI7umVuE3MUSj9G4dd5har2NP2qhmFWVXYFn242ca0t5adA16jatn71fQ=\nx-forefront-antispam-report:\n CIP:255.255.255.255;CTRY:;LANG:en;SCL:1;SRV:;IPV:NLI;SFV:NSPM;H:DM8PR09MB7205.namprd09.prod.outlook.com;PTR:;CAT:NONE;SFS:(13230028)(50650200015)(396003)(39830400003)(136003)(366004)(451199021)(42882007)(41300700001)(108616005)(83380400001)(9686003)(6506007)(71200400001)(24736004)(7696005)(508600001)(88996005)(66556008)(64756008)(66476007)(6916009)(66446008)(41320700001)(55016003)(66946007)(78352004)(122000001)(5660300002)(2906002)(15974865002)(40140700001)(8936002)(8676002)(111220200008)(80100003);DIR:OUT;SFP:1501;\nx-ms-exchange-antispam-messagedata-chunkcount: 1\nx-ms-exchange-antispam-messagedata-0:\n +yQivJt1LYRQ9uHQpvCyqgLrQU5JX17Wrv4326Wc8ljS1ti5FFVKUzOxvbecQfyhsr5RePRN+ZaidXzttA2AGq9LHpczP3y51IbuS0l1MInLDAM1d7uyuLsOe6XkB94BUm1UjUFwd0dxrxEcGGwbmhrp1KCIzDgqjVIxOSoeGuIi5EJpX6xL5GJfkrD0ZsF8hat8E6EOrVt/8vBrORb4TMF1qlAHtM8hODwfNF+fzZOMm85CPau9mfe0aT2mbSUYNHIYIBrho12HlcjRu9dSs2PQPGKXmfiLn/3dyZZwC1+fz/DGX6+0y13R6YPwhDJU\nContent-Type: multipart/alternative;\n\tboundary=\"_000_2dca75707c624d9e9fa2ca731155aa34DM8PR09MB7205namprd09pr_\"\nMIME-Version: 1.0\nX-OriginatorOrg: bphc.org\nX-MS-Exchange-CrossTenant-AuthAs: Internal\nX-MS-Exchange-CrossTenant-AuthSource: DM8PR09MB7205.namprd09.prod.outlook.com\nX-MS-Exchange-CrossTenant-Network-Message-Id: febdd29a-c5c3-4f17-c55a-08db525f4636\nX-MS-Exchange-CrossTenant-originalarrivaltime: 11 May 2023 20:35:35.5953\n (UTC)\nX-MS-Exchange-CrossTenant-fromentityheader: Hosted\nX-MS-Exchange-CrossTenant-id: ff5b5bc8-925b-471f-942a-eb176c03ab36\nX-MS-Exchange-Transport-CrossTenantHeadersStamped: SA1PR09MB8987\n\n--_000_2dca75707c624d9e9fa2ca731155aa34DM8PR09MB7205namprd09pr_\nContent-Type: text/plain; charset=\"iso-8859-1\"\nContent-Transfer-Encoding: quoted-printable\n\nThank you for contacting the Boston Public Health Commission (BPHC). We rec=\neived your email and BPHC staff will respond to your message within three b=\nusiness days. If your email requires a faster response, Boston residents ca=\nn call:\n\n=B7 BPHC Main Line: (617) 534-5395 (Monday through Friday, 9am to 5pm)\n=B7 Mayor's Hotline: (617) 635-4500 (after 5pm, during the weekends)\n\nDuring the current COVID-19 public health crisis, BPHC continues to protect=\n, preserve, and promote the health and well-being of all Boston residents, =\nparticularly the most vulnerable. If you are a Boston resident with questio=\nns about COVID-19, you can find the most current information about COVID-19=\n, testing, vaccines and boosters, and best health practices at our website,=\n www.boston.gov/covid19\n\nTo find information about COVID-19 testing sites in Boston, please visit ou=\nr COVID-19 Testing Site Page: www.boston.gov/covid19-testing\n\nTo find information about COVID-19 vaccine and booster sites in Boston, ple=\nase visit our COVID-19 Vaccine Page: www.boston.gov/covid19-vaccines\n\nIf you are seeking a replacement COVID-19 vaccine card, you will need to re=\nach out to the vaccine administer directly for a copy of your records. CIC =\nHealth administered vaccines at the Reggie Lewis Center Vaccine Site. You m=\nay contact them directly with vaccine questions at (888) 623-3830 or by ema=\niling vaccine-support@cic-health.com\n\nFor those living outside Boston please contact the Massachusetts Department=\n of Public Health at (617) 624-6000 or visit their website, www.mass.gov/co=\nvid19.\n\nPlease use the following additional resources for assistance:\n\n\n1) Boston residents call Mayor's Health Line: 617-534-5050/ Toll free: 1-80=\n0-847-0710 for information about finding a primary care provider; applying =\nfor health insurance; food pantries; Boston Public Health School lunch site=\ns; COVID symptoms; COVID19 cleaning practices; when to call your doctor v. =\nemergency room; donating medical supplies; and other related information.\n\n2) Boston residents call \"311\" for information about parking rules and tick=\nets; needle/litter clean up; street cleaning; getting rid of a big item; re=\nport a broken street sign; and non-emergency COVID-related issues.\n\n3) Massachusetts residents call \"211\" for information about testing sites; =\nCOVID19 symptoms; Latest state-wide orders; benefit programs (SNAP, unemplo=\nyment), Call2Talk - over the phone behavioral health services/support; and =\nother related information.\n\n--_000_2dca75707c624d9e9fa2ca731155aa34DM8PR09MB7205namprd09pr_\nContent-Type: text/html; charset=\"iso-8859-1\"\nContent-Transfer-Encoding: quoted-printable\n\n\n\n\n\n\nThank you for contacting the Boston Public Health Commission (BPHC). We rec=\neived your email and BPHC staff will respond to your message within three b=\nusiness days. If your email requires a faster response, Boston residents ca=\nn call:
\n
\n=B7 BPHC Main Line: (617) 534-5395 (Monday through Friday, 9am to 5pm)
\n=B7 Mayor's Hotline: (617) 635-4500 (after 5pm, during the weekends)
\n
\nDuring the current COVID-19 public health crisis, BPHC continues to protect=\n, preserve, and promote the health and well-being of all Boston residents, =\nparticularly the most vulnerable. If you are a Boston resident with questio=\nns about COVID-19, you can find\n the most current information about COVID-19, testing, vaccines and booster=\ns, and best health practices at our website, www.boston.gov/covid19  <=\nbr>\n
\nTo find information about COVID-19 testing sites in Boston, please visit ou=\nr COVID-19 Testing Site Page: www.boston.gov/covid19-testing
\n
\nTo find information about COVID-19 vaccine and booster sites in Boston, ple=\nase visit our COVID-19 Vaccine Page: www.boston.gov/covid19-vaccines
\n
\nIf you are seeking a replacement COVID-19 vaccine card, you will need to re=\nach out to the vaccine administer directly for a copy of your records. CIC =\nHealth administered vaccines at the Reggie Lewis Center Vaccine Site. You m=\nay contact them directly with vaccine\n questions at (888) 623-3830 or by emailing vaccine-support@cic-health.com<=\nbr>\n
\nFor those living outside Boston please contact the Massachusetts Department=\n of Public Health at (617) 624-6000 or visit their website, www.mass.gov/co=\nvid19.
\n
\nPlease use the following additional resources for assistance:
\n
\n
\n1) Boston residents call Mayor's Health Line: 617-534-5050/ Toll free: 1-80=\n0-847-0710 for information about finding a primary care provider; applying =\nfor health insurance; food pantries; Boston Public Health School lunch site=\ns; COVID symptoms; COVID19 cleaning\n practices; when to call your doctor v. emergency room; donating medical su=\npplies; and other related information.
\n
\n2) Boston residents call "311" for information about parking rule=\ns and tickets; needle/litter clean up; street cleaning; getting rid of a bi=\ng item; report a broken street sign; and non-emergency COVID-related issues=\n.
\n
\n3) Massachusetts residents call "211" for information about testi=\nng sites; COVID19 symptoms; Latest state-wide orders; benefit programs (SNA=\nP, unemployment), Call2Talk - over the phone behavioral health services/sup=\nport; and other related information.\n\n\n\n--_000_2dca75707c624d9e9fa2ca731155aa34DM8PR09MB7205namprd09pr_--\n", + "Tag": "", + "Headers": [ + { + "Name": "Return-Path", + "Value": "" + }, + { + "Name": "Received", + "Value": "by p-pm-inboundg02c-aws-useast1c.inbound.postmarkapp.com (Postfix, from userid 996)\tid 9B88B453CA3; Thu, 11 May 2023 20:35:38 +0000 (UTC)" + }, + { + "Name": "X-Spam-Checker-Version", + "Value": "SpamAssassin 3.4.0 (2014-02-07) on\tp-pm-inboundg02c-aws-useast1c" + }, + { + "Name": "X-Spam-Status", + "Value": "No" + }, + { + "Name": "X-Spam-Score", + "Value": "1.9" + }, + { + "Name": "X-Spam-Tests", + "Value": "DKIM_SIGNED,DKIM_VALID,DKIM_VALID_AU,FORGED_SPF_HELO,\tHTML_MESSAGE,PDS_BAD_THREAD_QP_64,RCVD_IN_DNSWL_NONE,\tRCVD_IN_ZEN_BLOCKED_OPENDNS,SPF_HELO_PASS,T_SCC_BODY_TEXT_LINE,\tURIBL_DBL_BLOCKED_OPENDNS,URIBL_ZEN_BLOCKED_OPENDNS" + }, + { + "Name": "Received-SPF", + "Value": "pass (gcc02-bl0-obe.outbound.protection.outlook.com: Sender is authorized to use 'GCC02-BL0-obe.outbound.protection.outlook.com' in 'helo' identity (mechanism 'include:spf.protection.outlook.com' matched)) receiver=p-pm-inboundg02c-aws-useast1c; identity=helo; helo=GCC02-BL0-obe.outbound.protection.outlook.com; client-ip=52.100.153.248" + }, + { + "Name": "Received", + "Value": "from GCC02-BL0-obe.outbound.protection.outlook.com (mail-bl0gcc02hn2248.outbound.protection.outlook.com [52.100.153.248])\t(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))\t(No client certificate requested)\tby p-pm-inboundg02c-aws-useast1c.inbound.postmarkapp.com (Postfix) with ESMTPS id 18A04453CA2\tfor ; Thu, 11 May 2023 20:35:38 +0000 (UTC)" + }, + { + "Name": "ARC-Seal", + "Value": "i=1; a=rsa-sha256; s=arcselector9901; d=microsoft.com; cv=none; b=jj6N9vbdzMkbk8C9u65DF2AFH42m50PODQZzrTWWtjRlzLcxXbYHC8Kp3nBHn5EG9/zxEgNRvRQyY4bbs6ClL0cJ205BwlUVi5asrB/mClpfAryWjcYx63xKL6/lleHujD1lv1AP+v8uZTJjxLun16JrQpQn/0duv9s/MORtiTHVYWBXeRWjfvIz185ady0SySOq/+OOJNTUxAkxsa3Mc7dEB6eN5HuPyHfJ+OKp1ZKkMrsIIs4gsnw51XaRkjSaMPmSOwQQJTNTzupt6JwLG9rLSOEBmq9jIJx+BcyVW9zYDzhcfsbanUN+eNizkNz+Z/CBwTT7SS7O9wc2eOBJJQ==" + }, + { + "Name": "ARC-Message-Signature", + "Value": "i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com; s=arcselector9901; h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-AntiSpam-MessageData-ChunkCount:X-MS-Exchange-AntiSpam-MessageData-0:X-MS-Exchange-AntiSpam-MessageData-1; bh=KA6k2PkR+L1ueLPEaPoMLGed3YXFrrljNLt+SeQ68gI=; b=bTV/qXKJCSV7D8HmtUX2UcxVdAKsJWkyzN3BdwwJQ2S7PgZSlZr3K0Bz0/ELP3P0CUvjXhpjN30I5Td1dhh1QnK9zKtSg7ZDjtEdRoOVBfwutnnM0WdTGl/e1P93WNiUfHfQoLgeUGlwm7fBRPxD005vF3LeV6BgMOxpxy2Bb8PhEeXExw6Xj/jqHgPRsTOBK3QokgzsMT3skT4hed019xME3jqz0Sb9a/n1yGkpOCgijDbAqiaVTpg39z3GlD3iBzWrziT4kEsy0BjrQXclbqJmJkPZJZRuKnLykhY8Qerdq3NbbyVK4swi0euIQO1lDV+bDdwVHNvoB5PQyp2aqQ==" + }, + { + "Name": "ARC-Authentication-Results", + "Value": "i=1; mx.microsoft.com 1; spf=none; dmarc=pass action=none header.from=bphc.org; dkim=pass header.d=bphc.org; arc=none" + }, + { + "Name": "DKIM-Signature", + "Value": "v=1; a=rsa-sha256; c=relaxed/relaxed; d=bphc.org; s=selector2; h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck; bh=KA6k2PkR+L1ueLPEaPoMLGed3YXFrrljNLt+SeQ68gI=; b=pY2DTo39+ER8bre34Y2S0lgE++js3gxKb8OBIHjFx2paOwkhJveEBJHJkxkWsrE6A8Uz5FH+ozHZFufhqtpNQwBLZ+mUK5l+YFg+dYlsA8MAM3ZRlbE0Om+hC+WgLYDBwekDBdMJwsJHGzCwyqiHj9qewp9IeqL1AuB586B/L7w=" + }, + { + "Name": "Received", + "Value": "from DM8PR09MB7205.namprd09.prod.outlook.com (2603:10b6:5:2ea::17) by SA1PR09MB8987.namprd09.prod.outlook.com (2603:10b6:806:28d::16) with Microsoft SMTP Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.6387.22; Thu, 11 May 2023 20:35:35 +0000" + }, + { + "Name": "Received", + "Value": "from DM8PR09MB7205.namprd09.prod.outlook.com ([::1]) by DM8PR09MB7205.namprd09.prod.outlook.com ([fe80::4c61:f919:1a19:e21a%4]) with Microsoft SMTP Server id 15.20.6387.020; Thu, 11 May 2023 20:35:35 +0000" + }, + { + "Name": "Thread-Topic", + "Value": "Proper disposal of pathological liquids" + }, + { + "Name": "Thread-Index", + "Value": "AQHZhEgj+jgKWSu0QUGFLyYcmlnYGa9Vh7ci" + }, + { + "Name": "Message-ID", + "Value": "<2dca75707c624d9e9fa2ca731155aa34@DM8PR09MB7205.namprd09.prod.outlook.com>" + }, + { + "Name": "References", + "Value": "" + }, + { + "Name": "In-Reply-To", + "Value": "" + }, + { + "Name": "X-MS-Has-Attach", + "Value": "" + }, + { + "Name": "X-Auto-Response-Suppress", + "Value": "All" + }, + { + "Name": "X-MS-Exchange-Inbox-Rules-Loop", + "Value": "info@bphc.org" + }, + { + "Name": "X-MS-TNEF-Correlator", + "Value": "" + }, + { + "Name": "authentication-results", + "Value": "dkim=none (message not signed) header.d=none;dmarc=none action=none header.from=bphc.org;" + }, + { + "Name": "x-ms-exchange-parent-message-id", + "Value": "" + }, + { + "Name": "auto-submitted", + "Value": "auto-generated" + }, + { + "Name": "x-ms-exchange-generated-message-source", + "Value": "Mailbox Rules Agent" + }, + { + "Name": "x-ms-publictraffictype", + "Value": "Email" + }, + { + "Name": "x-ms-traffictypediagnostic", + "Value": "DM8PR09MB7205:EE_|SA1PR09MB8987:EE_" + }, + { + "Name": "x-ms-office365-filtering-correlation-id", + "Value": "febdd29a-c5c3-4f17-c55a-08db525f4636" + }, + { + "Name": "x-ms-exchange-senderadcheck", + "Value": "1" + }, + { + "Name": "x-ms-exchange-antispam-relay", + "Value": "0" + }, + { + "Name": "x-microsoft-antispam", + "Value": "BCL:0;" + }, + { + "Name": "x-microsoft-antispam-message-info", + "Value": "xAGG2I7C6b/RY4hW+qwrkIoXgwl1ofcFIeVK2ehyn/jQXkJHANV4/yhBBe7T5zX7lYfL5eknK0zN+tzvzVBxqibPBCvxaNsj+CLFNOp097SO3RaePW4Yt88AQM1/O9OSQFOEhEB7qSN35Zqy6VC7/ijKH8aCENL4u6KNayclw/6g31jgAIrHxpiGZZA/mvLy6NNpdsQOUjR85mEvsP1+BirOALWzsmSgSU1peSo0P9vWU3rUWkjMjqtlDzr7Tr2knzTXgq5mR+gDUQ87OHlZms/i4qC5A6Y1BlW5937NGi9Lh4N3wh+I94ak63BT8CwCfIO/XVpMy2AEJaJkholv3D4DzAMFn7y8rkaZi3i3xauUMAuJuFRx5mFjGTFSdCkd8YypUotYToXFGgRpxPjwJFN7swABHgr1t2xhO4JR1LSIE9apqheptYzUZZhv8P77D9WTBozD1aohHZN2S4GY3iwHSPuVrX1pXeXU7Oe+9pb3iHBbfUCQYzn64Hi0gzfK+SMWCPHrJTL6+TCNiR6FkcfwZV0v7TKZDaqrnMUJm1x7yiCWwHHI7umVuE3MUSj9G4dd5har2NP2qhmFWVXYFn242ca0t5adA16jatn71fQ=" + }, + { + "Name": "x-forefront-antispam-report", + "Value": "CIP:255.255.255.255;CTRY:;LANG:en;SCL:1;SRV:;IPV:NLI;SFV:NSPM;H:DM8PR09MB7205.namprd09.prod.outlook.com;PTR:;CAT:NONE;SFS:(13230028)(50650200015)(396003)(39830400003)(136003)(366004)(451199021)(42882007)(41300700001)(108616005)(83380400001)(9686003)(6506007)(71200400001)(24736004)(7696005)(508600001)(88996005)(66556008)(64756008)(66476007)(6916009)(66446008)(41320700001)(55016003)(66946007)(78352004)(122000001)(5660300002)(2906002)(15974865002)(40140700001)(8936002)(8676002)(111220200008)(80100003);DIR:OUT;SFP:1501;" + }, + { + "Name": "x-ms-exchange-antispam-messagedata-chunkcount", + "Value": "1" + }, + { + "Name": "x-ms-exchange-antispam-messagedata-0", + "Value": "+yQivJt1LYRQ9uHQpvCyqgLrQU5JX17Wrv4326Wc8ljS1ti5FFVKUzOxvbecQfyhsr5RePRN+ZaidXzttA2AGq9LHpczP3y51IbuS0l1MInLDAM1d7uyuLsOe6XkB94BUm1UjUFwd0dxrxEcGGwbmhrp1KCIzDgqjVIxOSoeGuIi5EJpX6xL5GJfkrD0ZsF8hat8E6EOrVt/8vBrORb4TMF1qlAHtM8hODwfNF+fzZOMm85CPau9mfe0aT2mbSUYNHIYIBrho12HlcjRu9dSs2PQPGKXmfiLn/3dyZZwC1+fz/DGX6+0y13R6YPwhDJU" + }, + { + "Name": "MIME-Version", + "Value": "1.0" + }, + { + "Name": "X-OriginatorOrg", + "Value": "bphc.org" + }, + { + "Name": "X-MS-Exchange-CrossTenant-AuthAs", + "Value": "Internal" + }, + { + "Name": "X-MS-Exchange-CrossTenant-AuthSource", + "Value": "DM8PR09MB7205.namprd09.prod.outlook.com" + }, + { + "Name": "X-MS-Exchange-CrossTenant-Network-Message-Id", + "Value": "febdd29a-c5c3-4f17-c55a-08db525f4636" + }, + { + "Name": "X-MS-Exchange-CrossTenant-originalarrivaltime", + "Value": "11 May 2023 20:35:35.5953 (UTC)" + }, + { + "Name": "X-MS-Exchange-CrossTenant-fromentityheader", + "Value": "Hosted" + }, + { + "Name": "X-MS-Exchange-CrossTenant-id", + "Value": "ff5b5bc8-925b-471f-942a-eb176c03ab36" + }, + { + "Name": "X-MS-Exchange-Transport-CrossTenantHeadersStamped", + "Value": "SA1PR09MB8987" + } + ], + "Attachments": [] +} + +> {% + // TODO: migrate to HTTP Client Response handler API + // pm.test("Status code is 200", function () { + // pm.response.to.have.status(200); + // }); + // pm.test("Status is 'Success'", function () { + // pm.expect(pm.response.text()).to.include("success"); + // }); + // pm.test("Response is 'Message Sent'", function () { + // pm.expect(pm.response.text()).to.include("Message sent"); + // }); +%} + +### +# group: bos_email / Drupal mail +# @name Test Drupal Mail : : Bearer Token +POST {{url}}/rest/email/test/drupal/TestDrupalmail/plain +Content-Type: application/json +Authorization: Token {{bos_email_bearer_token}} + +{ + "To": "{{email_test_recipient}}", + "Cc": "", + "Bcc": "", + "Subject": "Postman: Test plaintext email", + "TextBody": "This is some text.\nThis is more text\\nThis is a link: https://www.mass.gov/covid19 ", + "Attachments": [] +} + +> {% + // TODO: migrate to HTTP Client Response handler API + // pm.test("Status code is 200", function () { + // pm.response.to.have.status(200); + // }); + // pm.test("Status is 'Success'", function () { + // pm.expect(pm.response.text()).to.include("success"); + // }); + // pm.test("Response is 'Message Sent'", function () { + // pm.expect(pm.response.text()).to.include("Message sent"); + // }); +%} + +### +# group: bos_email / fail-test +# @name Bad Session Token +POST {{url}}/rest/email_session/contactform +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/x-www-form-urlencoded + +email[token_session] = bad-session-token & +email[to_address] = {{email_test_recipient}} & +email[name] = Valid Email Recipient & +email[from_address] = digital-dev@boston.gov & +email[subject] = SHOULD NOT APPEAR IN POSTMARK & +email[message] = This message is sent with a bad session token. & +email[url] = https://www.boston.goc/somepage & +email[browser] = PostmanRuntime/7.29.2 & +email[contact] = + +> {% + // TODO: migrate to HTTP Client Response handler API + // pm.test("Status code is 401", function () { + // pm.response.to.have.status(403); + // }); + // pm.test("Status is 'error'", function () { + // pm.expect(pm.response.text()).to.include("error"); + // }); + // pm.test("Response is 'could not authenticate'", function () { + // pm.expect(pm.response.text()).to.include("invalid token"); + // }); +%} + +### +# group: bos_email / fail-test +# @name Bad/No token in sanitation +POST {{url}}/rest/email/sanitation +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/json +Authorization: Token asdfghjklqwertyui + +{ + "to_address": {{email_test_recipient}}, + "from_address": "Sanitation ", + "subject": "Sanitation Confirmation", + "message": "We are pleased to confirm your pickup", + "type": "confirmation" +} + +> {% + client.test("Status code is 401", function () { + client.assert(response.status == 401, "Response HTTP Code is not 401"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response - could not authenticate", function () { + client.assert(response.body.response == "could not authenticate", "Response status field is not 'could not authenticate'"); + }); +%} + +### +# group: bos_email / fail-test +# @name Missing Bearer Token +POST {{url}}/rest/email/contactform +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/x-www-form-urlencoded + +email[to_address] = {{email_test_recipient}} & +email[name] = Valid Email Recipient & +email[from_address] = digital-dev@boston.gov & +email[subject] = SHOULD NOT APPEAR IN POSTMARK & +email[message] = This message is sent with a bad token. & +email[url] = https://www.boston.gov/somepage & +email[browser] = PostmanRuntime/7.29.2 & +email[contact] = + +> {% + // TODO: migrate to HTTP Client Response handler API + // pm.test("Status code is 401", function () { + // pm.response.to.have.status(401); + // }); + // pm.test("Status is 'error'", function () { + // pm.expect(pm.response.text()).to.include("error"); + // }); + // pm.test("Response is 'could not authenticate'", function () { + // pm.expect(pm.response.text()).to.include("could not authenticate"); + // }); +%} + +### +# group: bos_email / fail-test +# @name Blocked User +POST {{url}}/rest/email/contactform +Cookie: XDEBUG_SESSION=PHPSTORM +#X-PM-Bounce-Type: hardbounce +Content-Type: application/x-www-form-urlencoded +Authorization: Token {{bos_email_bearer_token}} + +email[to_address] = blocked@boston.gov & +email[name] = Blocked Email Recipient & +email[from_address] = HardBounce@bounce-testing.postmarkapp.com & +email[subject] = SHOULD NOT APPEAR IN POSTMARK & +email[message] = This message is sent to a blocked user. & +email[url] = https://www.boston.gov/somepage & +email[browser] = PostmanRuntime/7.29.2 & +email[contact] = + +> {% + // TODO: migrate to HTTP Client Response handler API + // pm.test("Status code is 200", function () { + // pm.response.to.have.status(200); + // }); + // pm.test("Status is 'success'", function () { + // pm.expect(pm.response.text()).to.include("success"); + // }); + // pm.test("Response is 'Message queued'", function () { + // pm.expect(pm.response.text()).to.include("Message queued"); + // }); +%} + +### +# group: bos_email / fail-test +# @name Honeypot Fail +POST {{url}}/rest/email/contactform +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/x-www-form-urlencoded +Authorization: Token {{bos_email_bearer_token}} + +email[to_address] = {{email_test_recipient}} & +email[name] = Test Person & +email[from_address] = digital-dev@boston.gov & +email[subject] = SHOULD NOT APPEAR IN POSTMARK & +email[message] = This email has a value in the honeypot & +email[url] = https://www.boston.gov/somepage & +email[browser] = PostmanRuntime/7.29.2 & +email[contact] = should be empty + +> {% + // TODO: migrate to HTTP Client Response handler API + // pm.test("Status code is 200", function () { + // pm.response.to.have.status(200); + // }); + // pm.test("Status is 'error'", function () { + // pm.expect(pm.response.text()).to.include("success"); + // }); + // pm.test("Response is 'Message sent!'", function () { + // pm.expect(pm.response.text()).to.include("Message sent!"); + // }); +%} + +### +# group: bos_email / fail-test +# @name Bad email +POST {{url}}/rest/email/sanitation +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/json +Authorization: Token {{bos_email_bearer_token}} + +{ + "to_address": "bademail.com", + "from_address": "Sanitation ", + "subject": "Sanitation Confirmation", + "message": "We are pleased to confirm your pickup", + "type": "confirmation" +} + +> {% + client.test("Status code is 400", function () { + client.assert(response.status == 400, "Response HTTP Code is not 400"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "error", "Response status field is not 'error'"); + client.assert(response.body.response.indexOf("email is not valid") != false, "Response does not report a bad email address"); + }); +%} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.links.menu.yml b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.links.menu.yml new file mode 100644 index 0000000000..df73761c20 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.links.menu.yml @@ -0,0 +1,6 @@ +bos_email.admin.services: + title: 'Email Services' + description: 'Manage Email Services via boston.gov' + parent: bos_core.admin + route_name: bos_email.admin.services + weight: 1 diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.links.task.yml b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.links.task.yml new file mode 100644 index 0000000000..bd01f92e98 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.links.task.yml @@ -0,0 +1,5 @@ +bos_email.admin.services: + title: 'Email Services' + route_name: bos_email.admin.services + base_route: bos_core.admin + weight: 1 diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.module b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.module index 75b3e8a6e9..a43d15a024 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.module +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.module @@ -5,212 +5,6 @@ * The Base module file for bos_email module. */ -use Drupal\Core\Form\FormStateInterface; - -/** - * Implements hook_form_alter(). - */ -function bos_email_form_bos_core_admin_settings_alter(&$form, FormStateInterface $form_state) { - - if (!empty($form_state->getUserInput())) { - if ($input = $form_state->getUserInput()["bos_email"]) { - \Drupal::configFactory()->getEditable('bos_email.settings') - ->set("enabled", $input["enabled"]) - ->set("q_enabled", $input["q_enabled"]) - ->set("contactform.enabled", $input["contactform"]["enabled"] ?? 0) - ->set("contactform.q_enabled", $input["contactform"]["q_enabled"] ?? 0) - ->set("registry.enabled", $input["registry"]["enabled"] ?? 0) - ->set("registry.q_enabled", $input["registry"]["q_enabled"] ?? 0) - ->set("commissions.enabled", $input["commissions"]["enabled"] ?? 0) - ->set("commissions.q_enabled", $input["commissions"]["q_enabled"] ?? 0) - ->set("metrolist.enabled", $input["metrolist"]["enabled"] ?? 0) - ->set("metrolist.q_enabled", $input["metrolist"]["q_enabled"] ?? 0) - ->set("alerts.recipient", $input["alerts"]["conditions"]["recipient"] ?? "") - ->set("hardbounce.hardbounce", $input["alerts"]["hb"]["hardbounce"] ?? 0) - ->set("hardbounce.recipient", $input["alerts"]["hb"]["recipient"] ?? "") - ->set("alerts.recipient", $input["alerts"]["conditions"]["recipient"] ?? 0) - ->set("alerts.token", $input["alerts"]["conditions"]["token"] ?? 0) - ->set("alerts.honeypot", $input["alerts"]["conditions"]["honeypot"] ?? 0) - ->set("monitor.recipient", $input["alerts"]["monitoring"]["recipient"] ?? 0) - ->set("monitor.all", $input["alerts"]["monitoring"]["all"] ?? 0) - ->save(); - } - } - - else { - $config = \Drupal::configFactory()->get("bos_email.settings"); - $form["bos_email"] = [ - '#type' => 'details', - '#title' => 'PostMark Emailer', - '#markup' => 'Fine-grain management for emails sent via PostMark.', - '#open' => FALSE, - - "enabled" => [ - '#type' => 'checkbox', - '#title' => t('Postmark Email Service Enabled'), - '#description' => t('When selected, emails will be sent via Postmark. When unselected all emails are added to the queue.'), - '#default_value' => $config->get('enabled'), - ], - "q_enabled" => [ - '#type' => 'checkbox', - '#title' => t('Postmark-fail Queue Enabled'), - '#description' => t('When selected, emails that Postmark cannot process will be queued and there will be attempts to be resend. When unselected failed emails are discarded.'), - '#default_value' => $config->get('q_enabled'), - ], - - "alerts" => [ - '#type' => 'details', - '#title' => 'PostMark Email Alerts', - '#description' => 'Configure outbound emails for issues which arise with PostMark integration.', - '#open' => FALSE, - - "conditions" => [ - '#type' => 'fieldset', - '#title' => 'Service Abuse', - '#markup' => 'Emails will be sent to the recipient below when these potential abuse events occur:', - '#collapsible' => FALSE, - - "token" => [ - '#type' => 'checkbox', - '#title' => t('An incorrect API authentication token is provided. This could indicate a hacking attempt or attempted spam/relay abuse.'), - '#default_value' => $config->get('alerts.token') ?? 0, - ], - "honeypot" => [ - '#type' => 'checkbox', - '#title' => t('The honeypot field (a hidden input field a \'person\' cannot see or update) in a submitted form has data in it. This could indictate hacking attempt or attempted spam/relay abuse.'), - '#default_value' => $config->get('alerts.honeypot') ?? 0, - ], - "recipient" => [ - '#type' => 'textfield', - "#title" => "Email recipient", - "#description" => "The email (or email group) to receive hardbounce alerts.", - "#attributes" => ["placeholder" => "someone@boston.gov"], - "#default_value" => $config->get('alerts.recipient') ?? "", - ], - ], - - "monitoring" => [ - '#type' => 'fieldset', - '#title' => 'Service Monitoring', - '#markup' => 'Emails will be sent to the recipient below when these unexpected service error events occur:', - '#collapsible' => FALSE, - "all" => [ - '#type' => 'checkbox', - '#title' => t('All non-abuse failures when connecting to Postmark API.'), - '#default_value' => $config->get('monitor.all') ?? 0, - ], - "recipient" => [ - '#type' => 'textfield', - "#title" => "Email recipient", - "#description" => "The email (or email group) to receive service error emails.", - "#attributes" => ["placeholder" => "someone@boston.gov"], - "#default_value" => $config->get('monitor.recipient') ?? "", - ], - ], - - "hb" => [ - '#type' => 'fieldset', - '#title' => 'Hard Bounce / Recipient Supression', - '#markup' => 'Emails will be sent to the recipient below when the following normal conditions occur:', - '#collapsible' => FALSE, - - "hardbounce" => [ - '#type' => 'checkbox', - '#title' => t('The intended recipient is suppressed by PostMark.'), - '#default_value' => $config->get('hardbounce.hardbounce') ?? 0, - ], - "recipient" => [ - '#type' => 'textfield', - "#title" => "Email recipient", - "#description" => "The email (or email group) to receive hardbounce alerts.", - "#attributes" => ["placeholder" => "someone@boston.gov"], - "#default_value" => $config->get('hardbounce.recipient') ?? "", - ], - ], - - "footnote" => ['#markup' => "NOTE: These email alerts are sent via Drupal mail."], - ], - - "contactform" => [ - '#type' => 'fieldset', - '#title' => 'Contact Form', - '#markup' => 'Emails from the main Contact Form - when clicking on email addresses on boston.gov.', - '#collapsible' => FALSE, - - "enabled" => [ - '#type' => 'checkbox', - '#title' => t('Contact Form email service enabled'), - '#default_value' => $config->get('contactform.enabled'), - ], - "q_enabled" => [ - '#type' => 'checkbox', - '#title' => t('Contact Form queue processing enabled'), - '#description' => t('When selected, emails which initially fail to send and are queued will be processed on each cron run.'), - '#default_value' => $config->get('contactform.q_enabled'), - ], - ], - - "registry" => [ - '#type' => 'fieldset', - '#title' => 'Registry Suite', - '#markup' => 'Emails from the Registry App - confirmations.', - '#collapsible' => FALSE, - - "enabled" => [ - '#type' => 'checkbox', - '#title' => t('Registry email service enabled'), - '#default_value' => $config->get('registry.enabled'), - ], - "q_enabled" => [ - '#type' => 'checkbox', - '#title' => t('Registry queue processing enabled'), - '#description' => t('When selected, emails which initially fail to send and are queued will be processed on each cron run.'), - '#default_value' => $config->get('registry.q_enabled'), - ], - ], - - "commissions" => [ - '#type' => 'fieldset', - '#title' => 'Commissions App', - '#markup' => 'Emails from the Commissions App.', - '#collapsible' => FALSE, - - "enabled" => [ - '#type' => 'checkbox', - '#title' => t('Commission email service enabled'), - '#default_value' => $config->get('commissions.enabled'), - ], - "q_enabled" => [ - '#type' => 'checkbox', - '#title' => t('Commissions queue processing enabled'), - '#description' => t('When selected, emails which initially fail to send and are queued will be processed on each cron run.'), - '#default_value' => $config->get('commissions.q_enabled'), - ], - ], - - "metrolist" => [ - '#type' => 'fieldset', - '#title' => 'Metrolist Listing Form', - '#markup' => 'Emails sent from Metrolist Listing Form processes.', - '#collapsible' => FALSE, - - "enabled" => [ - '#type' => 'checkbox', - '#title' => t('metrolist email service Enabled'), - '#default_value' => $config->get('metrolist.enabled'), - ], - "q_enabled" => [ - '#type' => 'checkbox', - '#title' => t('metrolist queue processing enabled'), - '#description' => t('When selected, emails which initially fail to send and are queued will be processed on each cron run.'), - '#default_value' => $config->get('metrolist.q_enabled'), - ], - ], - - ]; - } -} - /** * Implements hook_mail(). */ diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml index 4d4e3c7557..d6f9325cc7 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml @@ -78,3 +78,11 @@ bos_email.drupalmail.test: no_cache: 'TRUE' requirements: _access: 'TRUE' + +bos_email.admin.services: + path: '/admin/config/system/boston/email_services' + defaults: + _title: 'Email Services' + _form: '\Drupal\bos_email\Form\ConfigForm' + requirements: + _role: "site_administrator" diff --git a/docroot/modules/custom/bos_components/modules/bos_email/http-client.env.json b/docroot/modules/custom/bos_components/modules/bos_email/http-client.env.json new file mode 100644 index 0000000000..3e03ceb99b --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/http-client.env.json @@ -0,0 +1,9 @@ +{ + "LOCAL for testing": { + "url": "https://boston.lndo.site", + "bos_email_bearer_token": "c43517a240ee61898c00600eaa775aa0d0e639322c3f275b780f66062f69", + "contact_form_session_token": "", + "registry_template_id": "31135208", + "email_test_recipient": "david.upton@boston.gov" + } +} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php b/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php index 5d0780e7a3..ccc49caa86 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php @@ -26,6 +26,7 @@ class CobEmail { ], "endpoint" => "", "server" => "", + "senddatetime" => "", ]; private array $fieldTypes = [ @@ -48,6 +49,7 @@ class CobEmail { "ReplyTo" => "email", ], "endpoint" => "string", + "senddatetime" => "string", ]; private array $requiredFields = [ @@ -149,7 +151,7 @@ public function validate(array $data = []) { $emailparts = explode("<", trim($email)); $mail = trim(array_pop($emailparts), " >"); if (!\Drupal::service('email.validator')->isValid($mail)) { - $this->validation_errors[] = "{$field} email is not valid ({$value}"; + $this->validation_errors[] = "{$field} email is not valid ({$value})"; $validated = FALSE; } } @@ -549,4 +551,8 @@ public function removeEmpty() { } } + public function is_scheduled() { + return !empty($this->emailFields["senddatetime"]); + } + } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/DrupalmailAPI.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/DrupalmailAPI.php index ffb07eb501..0951ec010f 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/DrupalmailAPI.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/DrupalmailAPI.php @@ -99,8 +99,8 @@ public function addQueueItem(array $data) { * @return bool */ private function authenticate() { - $postmark_auth = new PostmarkOps(); - return $postmark_auth->checkAuth($this->request->getCurrentRequest()->headers->get("authorization")); + $email_ops = new PostmarkOps(); + return $email_ops->checkAuth($this->request->getCurrentRequest()->headers->get("authorization")); } /** diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkAPI.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkAPI.php index 51a78b1652..77f326968f 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkAPI.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkAPI.php @@ -12,6 +12,18 @@ /** * Postmark class for API. + * + * This token is best used when a client-side javascript code is used to call + * the bos_email APIs. + * The workflow would be to call /rest/token/create from the server-side, save thes + * token which is returned and embed on the clientside. The client-side js then + * passes the token to the json submitted to /rest/email_token/{op} where it is + * validated and deleted. This way if the token is scraped from the client + * side, it can only be used once. + * + * For server-side sending of emails, just use the /rest/email/{op} which uses + * a bearer token. + * */ class PostmarkAPI extends ControllerBase { @@ -107,14 +119,29 @@ public function addQueueItem(array $data) { return $queue_item_id; } + /** + * Load an email into the queue for later dispatch. + * + * @param array $data + * The array containing the email POST data. + */ + public function addSheduledItem(array $data) { + $queue_name = 'scheduled_email'; + $queue = \Drupal::queue($queue_name); + $queue_item_id = $queue->createItem($data); + + return $queue_item_id; + } + + /** * Check the authentication key sent in the header is valid. * * @return bool */ private function authenticate() { - $postmark_auth = new PostmarkOps(); - return $postmark_auth->checkAuth($this->request->getCurrentRequest()->headers->get("authorization")); + $email_ops = new PostmarkOps(); + return $email_ops->checkAuth($this->request->getCurrentRequest()->headers->get("authorization")); } /** @@ -233,12 +260,12 @@ private function sendEmail(CobEmail $email) { } // Send the email. - $postmark_ops = new PostmarkOps(); - $sent = $postmark_ops->sendEmail($mailobj); + $email_ops = new PostmarkOps(); + $sent = $email_ops->sendEmail($mailobj); if (!$sent) { // Add email data to queue because of Postmark failure. - $mailobj["postmark_error"] = $postmark_ops->error; + $mailobj["postmark_error"] = $email_ops->error; $this->addQueueItem($mailobj); if ($this->debug) { @@ -304,7 +331,7 @@ public function begin(string $service = 'contactform') { ->getHttpHost(), "lndo.site"); $response_array = []; - if (in_array($service, ["contactform", "registry"])) { + if (in_array($service, ["contactform", "registry", "sanitation"])) { // This is done for legacy reasons (endpoint already in production and // in lowercase) $service = ucwords($service); @@ -364,7 +391,20 @@ public function begin(string $service = 'contactform') { // Format and validate the message body. if ($this->formatEmail($payload)) { // Send email. - $response_array = $this->sendEmail($payload["postmark_data"]); + if ($payload["postmark_data"]->is_scheduled()) { + $item = $payload["postmark_data"]->data(); + $id = $this->addSheduledItem($item); + if ($id && is_numeric($id)) { + $response_array = [ + 'status' => 'success', + 'response' => 'Message scheduled', + 'id' => $id + ]; + } + } + else { + $response_array = $this->sendEmail($payload["postmark_data"]); + } } else { PostmarkOps::alertHandler($payload, [], "", [], $this->error); diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkOps.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkOps.php index deacdcf254..d9445561df 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkOps.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkOps.php @@ -2,15 +2,17 @@ namespace Drupal\bos_email\Controller; +use Drupal\bos_core\Controllers\Curl\BosCurlControllerBase; use Drupal\Core\Site\Settings; use Exception; /** * Postmark variables for email API. */ -class PostmarkOps { +class PostmarkOps extends BosCurlControllerBase { - public string $error; + // Make this protected var from BosCurlControllerBase public + public null|string $error; /** * Check token and authenticate. @@ -198,7 +200,7 @@ public static function alertHandler($item, $response, $http_code, $config, $erro \Drupal::logger("bos_email:PostmarkOps")->warning(t("Email sending from Drupal has failed.")); } } - } + } // If no other issues, but the email failed to send. if (!isset($mailManager) diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailTemplateInterface.php b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailTemplateInterface.php index 4f4ac448ed..2d8f813652 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailTemplateInterface.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailTemplateInterface.php @@ -45,9 +45,11 @@ public static function templatePlainText(array &$emailFields): void; public static function templateHtmlText(array &$emailFields): void; /** + * Returns the payload field which is a honeypot for the form submitted. + * NOTE: Should return "" if there is no honeypot. + * * @return string The name of the honeypot field on the form (if any). * - * NOTE: Should return "" if there is no honeypot. */ public static function getHoneypotField(): string; diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Form/ConfigForm.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Form/ConfigForm.php new file mode 100644 index 0000000000..6b27dc4fa8 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Form/ConfigForm.php @@ -0,0 +1,267 @@ +configFactory->get(self::getEditableConfigNames()[0]); + $form["bos_email"] = [ + '#type' => 'fieldset', + '#title' => 'City of Boston Emailer', + '#markup' => 'Fine-grain management for emails sent via City of Boston REST API.', + "#tree" => TRUE, + + "service" => [ + "#type" => "select", + '#title' => t('Current Email Service'), + '#description' => t('The Email Service which is currently being used.'), + "#options" => [ + "drupal" => "Drupal", + "postmark" => "Postmark" + ], + '#default_value' => $config->get('service') + ], + + "enabled" => [ + '#type' => 'checkbox', + '#title' => t('Email Service Enabled'), + '#description' => t('When selected, emails will be sent via the indicated email service. When unselected all emails are added to the queue.'), + '#default_value' => $config->get('enabled'), + ], + "q_enabled" => [ + '#type' => 'checkbox', + '#title' => t('Email-fail Queue Enabled'), + '#description' => t('When selected, emails that the email service cannot process will be queued and there will be attempts to be resend. When unselected failed emails are discarded.'), + '#default_value' => $config->get('q_enabled'), + ], + + "alerts" => [ + '#type' => 'details', + '#title' => 'Email Service monitoring', + '#description' => 'Configure internal alert emails for issues which arise during operations.', + '#open' => FALSE, + + "conditions" => [ + '#type' => 'fieldset', + '#title' => 'Service Abuse', + '#markup' => 'Emails will be sent to the recipient below when these potential abuse events occur:', + '#collapsible' => FALSE, + + "token" => [ + '#type' => 'checkbox', + '#title' => t('An incorrect API authentication token is provided. This could indicate a hacking attempt or attempted spam/relay abuse.'), + '#default_value' => $config->get('alerts.token') ?? 0, + ], + "honeypot" => [ + '#type' => 'checkbox', + '#title' => t('The honeypot field (a hidden input field a \'person\' cannot see or update) in a submitted form has data in it. This could indictate hacking attempt or attempted spam/relay abuse.'), + '#default_value' => $config->get('alerts.honeypot') ?? 0, + ], + "recipient" => [ + '#type' => 'textfield', + "#title" => "Email recipient", + "#description" => "The email (or email group) to receive hardbounce alerts.", + "#attributes" => ["placeholder" => "someone@boston.gov"], + "#default_value" => $config->get('alerts.recipient') ?? "", + ], + ], + + "monitoring" => [ + '#type' => 'fieldset', + '#title' => 'Service Monitoring', + '#markup' => 'Emails will be sent to the recipient below when these unexpected service error events occur:', + '#collapsible' => FALSE, + "all" => [ + '#type' => 'checkbox', + '#title' => t('All non-abuse failures.'), + '#default_value' => $config->get('monitor.all') ?? 0, + ], + "recipient" => [ + '#type' => 'textfield', + "#title" => "Email recipient", + "#description" => "The email (or email group) to receive service error emails.", + "#attributes" => ["placeholder" => "someone@boston.gov"], + "#default_value" => $config->get('monitor.recipient') ?? "", + ], + ], + + "hb" => [ + '#type' => 'fieldset', + '#title' => 'Hard Bounce / Recipient Supression', + '#markup' => 'Emails will be sent to the recipient below when the following normal conditions occur:', + '#collapsible' => FALSE, + + "hardbounce" => [ + '#type' => 'checkbox', + '#title' => t('The intended recipient is suppressed by Email Service.'), + '#default_value' => $config->get('hardbounce.hardbounce') ?? 0, + ], + "recipient" => [ + '#type' => 'textfield', + "#title" => "Email recipient", + "#description" => "The email (or email group) to receive hardbounce alerts.", + "#attributes" => ["placeholder" => "someone@boston.gov"], + "#default_value" => $config->get('hardbounce.recipient') ?? "", + ], + ], + + "footnote" => ['#markup' => "NOTE: These email alerts are sent via Drupal mail."], + ], + + "contactform" => [ + '#type' => 'fieldset', + '#title' => 'Contact Form', + '#markup' => 'Emails from the main Contact Form - when clicking on email addresses on boston.gov.', + '#collapsible' => FALSE, + + "enabled" => [ + '#type' => 'checkbox', + '#title' => t('Contact Form email service enabled'), + '#default_value' => $config->get('contactform.enabled'), + ], + "q_enabled" => [ + '#type' => 'checkbox', + '#title' => t('Contact Form queue processing enabled'), + '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), + '#default_value' => $config->get('contactform.q_enabled'), + ], + ], + + "registry" => [ + '#type' => 'fieldset', + '#title' => 'Registry Suite', + '#markup' => 'Emails from the Registry App - confirmations.', + '#collapsible' => FALSE, + + "enabled" => [ + '#type' => 'checkbox', + '#title' => t('Registry email service enabled'), + '#default_value' => $config->get('registry.enabled'), + ], + "q_enabled" => [ + '#type' => 'checkbox', + '#title' => t('Registry queue processing enabled'), + '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), + '#default_value' => $config->get('registry.q_enabled'), + ], + ], + + "commissions" => [ + '#type' => 'fieldset', + '#title' => 'Commissions App', + '#markup' => 'Emails from the Commissions App.', + '#collapsible' => FALSE, + + "enabled" => [ + '#type' => 'checkbox', + '#title' => t('Commission email service enabled'), + '#default_value' => $config->get('commissions.enabled'), + ], + "q_enabled" => [ + '#type' => 'checkbox', + '#title' => t('Commissions queue processing enabled'), + '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), + '#default_value' => $config->get('commissions.q_enabled'), + ], + ], + + "metrolist" => [ + '#type' => 'fieldset', + '#title' => 'Metrolist Listing Form', + '#markup' => 'Emails sent from Metrolist Listing Form processes.', + '#collapsible' => FALSE, + + "enabled" => [ + '#type' => 'checkbox', + '#title' => t('Metrolist email service enabled'), + '#default_value' => $config->get('metrolist.enabled'), + ], + "q_enabled" => [ + '#type' => 'checkbox', + '#title' => t('Metrolist queue processing enabled'), + '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), + '#default_value' => $config->get('metrolist.q_enabled'), + ], + ], + + "sanitation" => [ + '#type' => 'fieldset', + '#title' => 'Sanitation Email Services', + '#markup' => 'Emails sent from Sanitation WebApp.', + '#collapsible' => FALSE, + + "enabled" => [ + '#type' => 'checkbox', + '#title' => t('Sanitation email service enabled'), + '#default_value' => $config->get('sanitation.enabled'), + ], + "q_enabled" => [ + '#type' => 'checkbox', + '#title' => t('Sanitation queue processing enabled'), + '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), + '#default_value' => $config->get('sanitation.q_enabled'), + ], + "sched_enabled" => [ + '#type' => 'checkbox', + '#title' => t('Sanitation scheduled email processing enabled'), + '#description' => t('When selected, scheduled emails are queued will be processed on each cron run.'), + '#default_value' => $config->get('sanitation.sched_enabled'), + ], + ], + + ]; + return parent::buildForm($form, $form_state); + } + + public function submitForm(array &$form, FormStateInterface $form_state) { + + if ($input = $form_state->getUserInput()["bos_email"]) { + $this->configFactory->getEditable(self::getEditableConfigNames()[0]) + ->set("service", $input["service"]) + ->set("enabled", $input["enabled"]) + ->set("q_enabled", $input["q_enabled"]) + ->set("contactform.enabled", $input["contactform"]["enabled"] ?? 0) + ->set("contactform.q_enabled", $input["contactform"]["q_enabled"] ?? 0) + ->set("registry.enabled", $input["registry"]["enabled"] ?? 0) + ->set("registry.q_enabled", $input["registry"]["q_enabled"] ?? 0) + ->set("commissions.enabled", $input["commissions"]["enabled"] ?? 0) + ->set("commissions.q_enabled", $input["commissions"]["q_enabled"] ?? 0) + ->set("metrolist.enabled", $input["metrolist"]["enabled"] ?? 0) + ->set("metrolist.q_enabled", $input["metrolist"]["q_enabled"] ?? 0) + ->set("sanitation.enabled", $input["sanitation"]["enabled"] ?? 0) + ->set("sanitation.sched_enabled", $input["sanitation"]["sched_enabled"] ?? 0) + ->set("sanitation.q_enabled", $input["sanitation"]["q_enabled"] ?? 0) + ->set("alerts.recipient", $input["alerts"]["conditions"]["recipient"] ?? "") + ->set("hardbounce.hardbounce", $input["alerts"]["hb"]["hardbounce"] ?? 0) + ->set("hardbounce.recipient", $input["alerts"]["hb"]["recipient"] ?? "") + ->set("alerts.recipient", $input["alerts"]["conditions"]["recipient"] ?? 0) + ->set("alerts.token", $input["alerts"]["conditions"]["token"] ?? 0) + ->set("alerts.honeypot", $input["alerts"]["conditions"]["honeypot"] ?? 0) + ->set("monitor.recipient", $input["alerts"]["monitoring"]["recipient"] ?? 0) + ->set("monitor.all", $input["alerts"]["monitoring"]["all"] ?? 0) + ->save(); + } + +// parent::submitForm($form, $form_state); + + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php index ff41ae73c4..ab0c6e9e57 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php @@ -2,6 +2,7 @@ namespace Drupal\bos_email\Plugin\QueueWorker; +use Drupal\Core\Annotation\QueueWorker; use Drupal\Core\Queue\QueueWorkerBase; use Drupal\bos_email\Controller\PostmarkOps; @@ -28,22 +29,22 @@ public function processItem($item) { $config = \Drupal::configFactory()->get("bos_email.settings"); if (!$config->get("q_enabled")) { - throw new \Exception("All queues are paused by settings at /admin/config/system/boston."); + throw new \Exception("All queues are paused by settings at /admin/config/system/boston/email_services."); } elseif (!empty($item["server"]) && !$config->get(strtolower($item["server"]))["q_enabled"]) { - throw new \Exception("The queue for {$item["server"]} is paused by settings at /admin/config/system/boston."); + throw new \Exception("The queue for {$item["server"]} is paused by settings at /admin/config/system/boston/email_services."); } if (!empty($item["postmark_error"])) { unset($item["postmark_error"]); } - $postmark_ops = new PostmarkOps(); - $postmark_send = $postmark_ops->sendEmail($item); + $email_ops = new PostmarkOps(); + $postmark_send = $email_ops->sendEmail($item); if (!$postmark_send) { - throw new \Exception("There was a problem in bos_email:PostmarkOps. {$postmark_ops->error}"); + throw new \Exception("There was a problem in bos_email:PostmarkOps. {$email_ops->error}"); } } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php new file mode 100644 index 0000000000..74a9cedcbb --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php @@ -0,0 +1,60 @@ +get("bos_email.settings"); + + if (!$config->get("q_enabled")) { + throw new \Exception("All queues are paused by settings at /admin/config/system/boston/email_services."); + } + elseif (!empty($item["server"]) + && !$config->get(strtolower($item["server"]))["q_enabled"]) { + throw new \Exception("The queue for {$item["server"]} is paused by settings at /admin/config/system/boston/email_services."); + } + + if (!empty($item["postmark_error"])) { + unset($item["postmark_error"]); + } + + if ($item["senddatetime"] <= strtotime("Now")) { + $email_ops = new PostmarkOps(); + $postmark_send = $email_ops->sendEmail($item); + } + + if (!$postmark_send) { + throw new \Exception("There was a problem in bos_email:PostmarkOps. {$email_ops->error}"); + } + + } + catch (\Exception $e) { + \Drupal::logger("contactform")->error($e->getMessage()); + throw new \Exception($e->getMessage()); + } + + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php new file mode 100644 index 0000000000..28eff71628 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php @@ -0,0 +1,100 @@ +setField("endpoint", PostmarkAPI::POSTMARK_TEMPLATE_ENDPOINT); + + // Set up the Postmark template. + $template_map = [ + "confirmation" => "sani_confirm", + "reminder1" => "sani_remind1", + "reminder2" => "sani_remind2", + "cancel" => "sani_cancel", + ]; + $cobdata->setField("TemplateID", $template_map[$emailFields["type"]]); + $cobdata->setField("TemplateModel", [ + "subject" => $emailFields["subject"], + "TextBody" => $emailFields["message"], + "ReplyTo" => $emailFields["from_address"] + ]); + $cobdata->delField("HtmlBody"); + $cobdata->delField("TextBody"); + $cobdata->delField("Subject"); + + // Set general email fields. + $cobdata->setField("To", $emailFields["to_address"]); + $cobdata->setField("From", $emailFields["from_address"]); + $cobdata->setField("ReplyTo", $emailFields["from_address"]); + + $cobdata->setField("Tag", $emailFields["type"]); + + // is this to be scheduled? + if (!empty($emailFields["senddatetime"])) { + try { + $senddatetime = strtotime($emailFields["senddatetime"]); + $cobdata->setField("senddatetime", $senddatetime); + } + catch (Exception $e) { + $cobdata->delField("senddatetime"); + } + } + else { + $cobdata->delField("senddatetime"); + } + + } + + /** + * @inheritDoc + */ + public static function templatePlainText(&$emailFields): void { + // Only use templates ATM. + } + + /** + * @inheritDoc + */ + public static function templateHtmlText(&$emailFields): void { + // Only use templates ATM. + } + + /** + * @inheritDoc + */ + public static function getHoneypotField(): string { + return ""; + } + + /** + * @inheritDoc + */ + public static function getServerID(): string { + return "sanitation"; + } + + /** + * @inheritDoc + */ + public static function formatInboundEmail(array &$emailFields): void { + // Not Used + } + +} From 937de38cdb7c2967df8f3fa3074260537d685dbb Mon Sep 17 00:00:00 2001 From: David Upton Date: Mon, 22 Apr 2024 13:57:48 -0400 Subject: [PATCH 02/48] DIG-4317 Implements scheduled sending and refactors. --- .../modules/bos_email/bos_email.http | 61 +- .../modules/bos_email/bos_email.routing.yml | 22 +- .../modules/bos_email/src/CobEmail.php | 80 ++- .../src/Controller/DrupalmailAPI.php | 251 ------- .../{PostmarkAPI.php => EmailController.php} | 637 ++++++++++++------ .../bos_email/src/Controller/PostmarkOps.php | 228 ------- .../bos_email/src/Controller/TokenOps.php | 60 -- .../bos_email/src/EmailServiceInterface.php | 22 + .../bos_email/src/EmailTemplateInterface.php | 22 +- .../modules/bos_email/src/Form/ConfigForm.php | 72 +- .../QueueWorker/ContactformProcessItems.php | 24 +- .../QueueWorker/ScheduledEmailProcessor.php | 51 +- .../bos_email/src/Services/DrupalService.php | 91 +++ .../src/Services/PostmarkService.php | 105 +++ .../bos_email/src/Services/TokenOps.php | 98 +++ .../bos_email/src/Templates/Contactform.php | 31 +- .../src/Templates/MetrolistInitiationForm.php | 23 +- .../MetrolistListingConfirmation.php | 23 +- .../MetrolistListingNotification.php | 23 +- .../bos_email/src/Templates/Registry.php | 20 +- .../bos_email/src/Templates/Sanitation.php | 29 +- .../src/Templates/TestDrupalmail.php | 4 +- .../src/Functional/EmailControllerTest.php | 66 ++ 23 files changed, 1159 insertions(+), 884 deletions(-) delete mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Controller/DrupalmailAPI.php rename docroot/modules/custom/bos_components/modules/bos_email/src/Controller/{PostmarkAPI.php => EmailController.php} (79%) delete mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkOps.php delete mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Controller/TokenOps.php create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Services/TokenOps.php create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/tests/src/Functional/EmailControllerTest.php diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http index aee952d5e7..f31caca1f6 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http @@ -57,7 +57,7 @@ email[contact] = ### # group: bos_email / sanitation -# @name Sanitation on-demand: Bearer Token +# @name Sanitation on-demand confirmation: Bearer Token POST {{url}}/rest/email/sanitation Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/json @@ -81,13 +81,13 @@ Authorization: Token {{bos_email_bearer_token}} }); client.test("Expected Response format", function () { client.assert(response.body.status == "success", "Response status field is not 'success'"); - client.assert(response.body.response == "Message sent" || response.body.response == "Message queued", "Response message is not expected"); + client.assert(response.body.response == "Message sent." || response.body.response == "Message queued.", "Response message is not expected"); }); %} ### # group: bos_email / sanitation -# @name Sanitation scheduled: Bearer Token +# @name Sanitation scheduled reminder: Bearer Token POST {{url}}/rest/email/sanitation Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/json @@ -99,7 +99,7 @@ Authorization: Token {{bos_email_bearer_token}} "subject": "Sanitation Confirmation", "message": "We are pleased to confirm your pickup", "type": "reminder1", - "senddatetime": "10/02/2024 15:00" + "senddatetime": "+1 week" } > {% @@ -116,6 +116,7 @@ Authorization: Token {{bos_email_bearer_token}} }); client.test("Response contains email id", function () { client.assert(typeof response.body.id !== "undefined" && response.body.id != "", "Response does not contain an ID field"); + client.global.set("sanitation_email_id", response.body.id); }); %} @@ -445,7 +446,6 @@ Content-Disposition: form-data; name="new" // }); %} - ### # group: bos_email / inbound # @name Incoming Webhook (POSTMARK) @@ -715,6 +715,25 @@ Authorization: Token {{bos_email_bearer_token}} // }); %} +### +# group: bos_email / Cancel +# @name Cancel Scheduled Email: Bearer Token +POST {{url}}/rest/email_cancel/sanitation +Content-Type: application/json +Authorization: Token {{bos_email_bearer_token}} + +{ + "id": "{{sanitation_email_id}}" +} + +> {% + client.test("Status code is 200", function () { + client.assert(response.status == 200, "Response HTTP Code is not 200"); + }); + + client.global.clear("sanitation_email_id"); + %} + ### # group: bos_email / fail-test # @name Bad Session Token @@ -754,7 +773,7 @@ Content-Type: application/json Authorization: Token asdfghjklqwertyui { - "to_address": {{email_test_recipient}}, + "to_address": "{{email_test_recipient}}", "from_address": "Sanitation ", "subject": "Sanitation Confirmation", "message": "We are pleased to confirm your pickup", @@ -893,3 +912,33 @@ Authorization: Token {{bos_email_bearer_token}} client.assert(response.body.response.indexOf("email is not valid") != false, "Response does not report a bad email address"); }); %} + +### +# group: bos_email / fail-test +# @name Bad scheduled date +POST {{url}}/rest/email/sanitation +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/json +Authorization: Token {{bos_email_bearer_token}} + +{ + "to_address": "{{email_test_recipient}}", + "from_address": "Sanitation ", + "subject": "Sanitation Confirmation", + "message": "We are pleased to confirm your pickup", + "type": "confirmation", + "senddatetime": "-1 days" +} + +> {% + client.test("Status code is 400", function () { + client.assert(response.status == 400, "Response HTTP Code is not 400"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response - Scheduled date is in the past", function () { + client.assert(response.body.response == "Scheduled date is in the past.", "Response status field is not 'Scheduled date is in the past'"); + }); +%} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml index d6f9325cc7..457e0abef6 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml @@ -1,7 +1,7 @@ bos_email.token: path: '/rest/email_token/{operation}' defaults: - _controller: '\Drupal\bos_email\Controller\PostmarkAPI::token' + _controller: '\Drupal\bos_email\Controller\EmailController::token' methods: [POST] options: no_cache: 'TRUE' @@ -11,7 +11,17 @@ bos_email.token: bos_email.send: path: '/rest/email/{service}' defaults: - _controller: '\Drupal\bos_email\Controller\PostmarkAPI::begin' + _controller: '\Drupal\bos_email\Controller\EmailController::begin' + methods: [POST] + options: + no_cache: 'TRUE' + requirements: + _access: 'TRUE' + +bos_email.cancel.scheduled: + path: '/rest/email_cancel/{service}' + defaults: + _controller: '\Drupal\bos_email\Controller\EmailController::cancelEmail' methods: [POST] options: no_cache: 'TRUE' @@ -21,7 +31,7 @@ bos_email.send: bos_email.send_token_session: path: '/rest/email_session/{service}' defaults: - _controller: '\Drupal\bos_email\Controller\PostmarkAPI::beginSession' + _controller: '\Drupal\bos_email\Controller\EmailController::beginSession' methods: [POST] options: no_cache: 'TRUE' @@ -32,7 +42,7 @@ bos_email.send_token_session: bos_email.send_legacy: path: '/emails' defaults: - _controller: '\Drupal\bos_email\Controller\PostmarkAPI::begin' + _controller: '\Drupal\bos_email\Controller\EmailController::begin' options: no_cache: 'TRUE' methods: [POST] @@ -62,7 +72,7 @@ bos_email.newsletter_manager_legacy: bos_email.postmark.webhook: path: '/rest/email/postmark/{service}/{stream}' defaults: - _controller: '\Drupal\bos_email\Controller\PostmarkAPI::callback' + _controller: '\Drupal\bos_email\Controller\EmailController::callback' methods: [POST] options: no_cache: 'TRUE' @@ -72,7 +82,7 @@ bos_email.postmark.webhook: bos_email.drupalmail.test: path: '/rest/email/test/drupal/{service}/{tag}' defaults: - _controller: '\Drupal\bos_email\Controller\DrupalmailAPI::begin' + _controller: '\Drupal\bos_email\Services\DrupalService::begin' methods: [POST] options: no_cache: 'TRUE' diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php b/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php index ccc49caa86..bd61f8da71 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php @@ -6,7 +6,7 @@ class CobEmail { - private array $emailFields = [ + private array $data = [ "To" => "", "ReplyTo" => "", "Cc" => "", @@ -26,6 +26,7 @@ class CobEmail { ], "endpoint" => "", "server" => "", + "service" => "", "senddatetime" => "", ]; @@ -48,6 +49,7 @@ class CobEmail { "TextBody" => "string", "ReplyTo" => "email", ], + "service" => "string", "endpoint" => "string", "senddatetime" => "string", ]; @@ -57,6 +59,7 @@ class CobEmail { "From", "endpoint", "server", + "service" ]; public const FIELD_STRING = "string"; @@ -65,7 +68,6 @@ class CobEmail { public const FIELD_HTML = "html"; public const FIELD_NUMBER = "number"; - public const HANDLER_POSTMARK = "postmark"; public const HANDLER_DRUPALMAIL = "drupalmail"; @@ -75,7 +77,7 @@ class CobEmail { /** * @const An array of headers to retain when processing header arrays. */ - private const KEEP = [ + private const KEEP_HEADERS = [ "Message-ID", "References", "In-Reply-To", @@ -104,7 +106,7 @@ class CobEmail { */ public function __construct(array $data = []) { - $this->emailFields = array_merge($this->emailFields, $data); + $this->data = array_merge($this->data, $data); if (!empty($data)) { // Only bring in fields which are pre-defined in $this->emailFields. @@ -115,10 +117,10 @@ public function __construct(array $data = []) { } // Run an initial validation -but don't fail if valoidation fails (just // set the validation_errors field). - $this->validate($this->emailFields); + $this->validate($this->data); } - return $this->emailFields; + return $this->data; } @@ -131,12 +133,12 @@ public function __construct(array $data = []) { * @return bool TRUE if validated, FALSE if not. If fails, then inspect * $this->validation_errors for causes. */ - public function validate(array $data = []) { + public function validate(array $data = []):bool { $this->validation_errors = []; if (empty($data)) { - $data = $this->emailFields; + $data = $this->data; } $validated = TRUE; @@ -200,7 +202,7 @@ public function validate(array $data = []) { * * @return array Array of errors from last validation. */ - public function getValidationErrors() { + public function getValidationErrors(): array { return $this->validation_errors; } @@ -209,7 +211,7 @@ public function getValidationErrors() { * * @return bool TRUE if errors else FALSE */ - public function hasValidationErrors() { + public function hasValidationErrors(): bool { return !empty($this->validation_errors); } @@ -219,13 +221,13 @@ public function hasValidationErrors() { * @param array|string|numeric $data An array of data to sanitize * (defaults to $this->>emailFields) * - * @return array|mixed The sanitized array. + * @return array The sanitized array. * @throws \Exception */ - private function sanitize($data = []) { + private function sanitize($data = []): array { if (empty($data)) { - $data = $this->emailFields; + $data = $this->data; } foreach ($data as $field => &$value) { @@ -325,7 +327,7 @@ private function sanitizeField(string $field, $value) { * @throws \Exception - if the type is not "string|array|email|html|number", * or (bubbles up) if the field cannot be sanitized. */ - public function addField(string $field, string $type, $value = "") { + public function addField(string $field, string $type, $value = ""): array { if (!array_key_exists($field, $this->fieldTypes)) { if (!in_array($type, ["string", "array", "email", "html", "number"])) { @@ -336,7 +338,7 @@ public function addField(string $field, string $type, $value = "") { $this->setField($field, $value); - return $this->emailFields; + return $this->data; } @@ -350,11 +352,11 @@ public function addField(string $field, string $type, $value = "") { * @return array The current sanitized but unvalidated fields in the object. * @throws \Exception (bubbles up) if the field cannot be sanitized. */ - public function setField(string $field, $value = "") { + public function setField(string $field, $value = ""): array { - $this->emailFields[$field] = $this->sanitizeField($field, $value); + $this->data[$field] = $this->sanitizeField($field, $value); - return $this->emailFields; + return $this->data; } @@ -366,7 +368,7 @@ public function setField(string $field, $value = "") { * @return false|mixed */ public function getField(string $field) { - return $this->emailFields[$field] ?? FALSE; + return $this->data[$field] ?? FALSE; } /** @@ -379,14 +381,14 @@ public function getField(string $field) { * @return void * @throws \Exception If field cannot be removed b/c is required. */ - public function delField(string $field) { + public function delField(string $field): void { if ($this->hasField($field)) { if (in_array($field, $this->requiredFields)) { throw new \Exception("Cannot delete required field {$field}"); } - unset($this->emailFields[$field]); + unset($this->data[$field]); if (isset($this->fieldTypes[$field])) { unset($this->fieldTypes[$field]); } @@ -400,8 +402,8 @@ public function delField(string $field) { * * @return bool */ - public function hasField($field) { - return isset($this->emailFields[$field]); + public function hasField($field): bool { + return isset($this->data[$field]); } /** @@ -410,12 +412,12 @@ public function hasField($field) { * @return array|false The emailFields currently set in the object. * @throws \Exception If validation fails. */ - public function data(bool $validate = TRUE) { + public function data(bool $validate = TRUE): array { if (!$validate) { - return $this->emailFields; + return $this->data; } - if ($this->validate($this->emailFields)) { - return $this->emailFields; + if ($this->validate($this->data)) { + return $this->data; } throw new \Exception("Validation errors occurred"); } @@ -485,8 +487,9 @@ public static function decodeFakeEmail(string $email, string $domain = "", $stri /** * Adds an array of headers to the email object, optionally keeping just the - * elements provided in the $keep array. Pass empty array to insert all array - * elements in $headers. + * elements provided in the $keep array. If $keep = NULL, then will use the + * default KEEP_HEADERS constant (array). TIP: Pass $keep = [] to retain all + * array elements in $headers. * * @param \Drupal\bos_email\CobEmail $email The email object * @param array $headers An array of headers to process @@ -495,7 +498,7 @@ public static function decodeFakeEmail(string $email, string $domain = "", $stri * @return void * @throws \Exception */ - public function processHeaders(array $headers, array $keep = self::KEEP) { + public function processHeaders(array $headers, array $keep = self::KEEP_HEADERS): void { if (!empty($keep)) { $_headers = []; @@ -543,16 +546,27 @@ private static function getDomain(): string { return implode(".", array_reverse($domain)); } + /** + * Removes empty elements from the emailfields array + * + * @return void + * @throws \Exception + */ public function removeEmpty() { - foreach($this->emailFields as $key => $value) { + foreach($this->data as $key => $value) { if ($value == "" && !in_array($key, $this->requiredFields)) { $this->delField($key); } } } - public function is_scheduled() { - return !empty($this->emailFields["senddatetime"]); + /** + * Returns true if the emailfields["senddatetime"] field is not empty. + * + * @return bool + */ + public function is_scheduled():bool { + return !empty($this->data["senddatetime"]); } } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/DrupalmailAPI.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/DrupalmailAPI.php deleted file mode 100644 index 0951ec010f..0000000000 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/DrupalmailAPI.php +++ /dev/null @@ -1,251 +0,0 @@ -request = $request; - } - - /** - * {@inheritdoc} - */ - public static function create(ContainerInterface $container) { - // Instantiates this form class. - return new static( - // Load the service required to construct this class. - $container->get('request_stack') - ); - } - - /** - * Check / set valid session token. - * - */ - public function token(string $operation) { - $data = $this->request->getCurrentRequest()->get('data'); - $token = new TokenOps(); - - if ($operation == "create") { - $response_token = $token->tokenCreate(); - - } - elseif ($operation == "remove") { - $response_token = $token->tokenRemove($data); - - } - else { - $response_token = $token->tokenGet($data); - - } - - $response = new CacheableJsonResponse($response_token); - return $response; - } - - /** - * Load an email into the queue for later dispatch. - * - * @param array $data - * The array containing the email POST data. - */ - public function addQueueItem(array $data) { - $queue_name = 'email_contactform'; - $queue = \Drupal::queue($queue_name); - $queue_item_id = $queue->createItem($data); - - return $queue_item_id; - } - - /** - * Check the authentication key sent in the header is valid. - * - * @return bool - */ - private function authenticate() { - $email_ops = new PostmarkOps(); - return $email_ops->checkAuth($this->request->getCurrentRequest()->headers->get("authorization")); - } - - /** - * Send the email via Postmark. - * - * @param \Drupal\bos_email\CobEmail $mailobj The email object - * - * @return array - */ - private function sendEmail(CobEmail $email) { - - /** - * @var $mailobj CobEmail - */ - - // Extract the email object, and validate. - try { - $mailobj = $email->data(); - } - catch (\Exception $e) {} - - if ($email->hasValidationErrors()) { - return [ - 'status' => 'failed', - 'response' => implode(":", $email->getValidationErrors()), - ]; - - } - - /** - * @var \Drupal\Core\Mail\MailManager $mailManager - */ - try { - - // Send the email. - $mailobj["_error_message"] = ""; - $key = "{$this->service}.{$mailobj["Tag"]}"; - - $mailManager = \Drupal::service('plugin.manager.mail'); - - $sent = $mailManager->mail("bos_email", $key , $mailobj["To"], "en", $mailobj, NULL, TRUE); - - if (!$sent || !$sent["result"]) { - if (!empty($params["_error_message"])) { - throw new \Exception($params["_error_message"]); - } - else { - throw new \Exception("Error sending email."); - } - } - - $response_message = self::MESSAGE_SENT; - - } - catch (\Exception $e) { - try { - $this->addQueueItem($mailobj); - } - catch (\Exception $ee) { - \Drupal::logger("bos_email:DrupalmailAPI")->info("Failed to queued mail item in {$email->getField("server")}"); - return [ - 'status' => 'error', - 'response' => "Error sending message {$e->getMessage()}, then error queueing item {$ee->getMessage()}.", - ]; - } - - if (Boston::is_local()) { - \Drupal::logger("bos_email:DrupalmailAPI")->info("Queued {$email->getField("server")}"); - } - - $response_message = self::MESSAGE_QUEUED; - } - - - return [ - 'status' => 'success', - 'response' => $response_message, - ]; - - } - - - /** - * Begin script and API operations. - * - * @param string $service - * The server being called via the endpoint uri. - * - * @return CacheableJsonResponse - * The json response to send to the endpoint caller. - */ - public function begin(string $service, string $tag) { - - if (Boston::is_local()) { - \Drupal::logger("bos_email:DrupalmailAPI") - ->info("Starts {$service} (callback)"); - } - - $this->service = $service; - - if ($this->authenticate()) { - - if ($this->request->getCurrentRequest()->getMethod() == "POST") { - - // Get the request payload. - $emailFields = $this->request->getCurrentRequest()->getContent(); - $emailFields = (array) json_decode($emailFields); - - // Format the email message. - if (class_exists("Drupal\\bos_email\\Templates\\{$service}") === TRUE) { - - $this->template_class = "Drupal\\bos_email\\Templates\\{$service}"; - - $emailFields["drupal_data"] = new CobEmail([ - "server" => $this->template_class::getServerID(), - "endpoint" => Boston::current_environment(), - "Tag" => $tag - ]); - - // Logging - if (Boston::is_local()) { - \Drupal::logger("bos_email:DrupalmailAPI") - ->info("Set data {$service}:
" . json_encode($emailFields)); - } - - $this->template_class::formatOutboundEmail($emailFields); - $response_array = $this->sendEmail($emailFields["drupal_data"]); - - if (Boston::is_local()) { - \Drupal::logger("bos_email:DrupalmailAPI") - ->info("Finished {$service}: " . json_encode($response_array)); - } - - } - - if (!empty($response_array)) { - return new CacheableJsonResponse($response_array, Response::HTTP_OK); - } - else { - return new CacheableJsonResponse(["error" => "Unknown"], Response::HTTP_BAD_REQUEST); - } - - } - - } - } - -} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkAPI.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/EmailController.php similarity index 79% rename from docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkAPI.php rename to docroot/modules/custom/bos_components/modules/bos_email/src/Controller/EmailController.php index 77f326968f..708198bca7 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkAPI.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/EmailController.php @@ -3,12 +3,15 @@ namespace Drupal\bos_email\Controller; use Boston; +use Drupal; use Drupal\bos_email\CobEmail; +use Drupal\bos_email\Services\TokenOps; use Drupal\Core\Cache\CacheableJsonResponse; use Drupal\Core\Controller\ControllerBase; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; +use Drupal\Core\Config\ImmutableConfig; /** * Postmark class for API. @@ -25,7 +28,7 @@ * a bearer token. * */ -class PostmarkAPI extends ControllerBase { +class EmailController extends ControllerBase { const MESSAGE_SENT = 'Message sent.'; @@ -36,6 +39,9 @@ class PostmarkAPI extends ControllerBase { const AUTORESPONDER_SERVERNAME = 'autoresponder'; + const EMAIL_QUEUE = "email_contactform"; + const EMAIL_QUEUE_SCHEDULED = "scheduled_email"; + /** * Current request object for this class. * @@ -43,18 +49,22 @@ class PostmarkAPI extends ControllerBase { */ public $request; + public ImmutableConfig $config; + /** * Server hosted / mapped to Postmark. * * @var string */ - public $server; + public $group_id; /** * @var boolean */ public $debug; + private $email_service; + private string $error = ""; /** @var \Drupal\bos_email\EmailTemplateInterface */ @@ -62,11 +72,14 @@ class PostmarkAPI extends ControllerBase { private string $honeypot; + private bool $authenticated = FALSE; + /** * Public construct for Request. */ - public function __construct(RequestStack $request) { + public function __construct(RequestStack $request, Drupal\Core\Config\ConfigFactory $config) { $this->request = $request; + $this->config = $config->get("bos_email.settings"); } /** @@ -76,10 +89,15 @@ public static function create(ContainerInterface $container) { // Instantiates this form class. return new static( // Load the service required to construct this class. - $container->get('request_stack') + $container->get('request_stack'), + $container->get('config.factory') ); } + /**************************** + * ENDPOINTS + ****************************/ + /** * Check / set valid session token. * @@ -89,15 +107,15 @@ public function token(string $operation) { $token = new TokenOps(); if ($operation == "create") { - $response_token = $token->tokenCreate(); + $response_token = $token->sessionTokenCreate(); } elseif ($operation == "remove") { - $response_token = $token->tokenRemove($data); + $response_token = $token->sessionTokenRemove($data); } else { - $response_token = $token->tokenGet($data); + $response_token = $token->sessionTokenGet($data); } @@ -105,188 +123,6 @@ public function token(string $operation) { return $response; } - /** - * Load an email into the queue for later dispatch. - * - * @param array $data - * The array containing the email POST data. - */ - public function addQueueItem(array $data) { - $queue_name = 'email_contactform'; - $queue = \Drupal::queue($queue_name); - $queue_item_id = $queue->createItem($data); - - return $queue_item_id; - } - - /** - * Load an email into the queue for later dispatch. - * - * @param array $data - * The array containing the email POST data. - */ - public function addSheduledItem(array $data) { - $queue_name = 'scheduled_email'; - $queue = \Drupal::queue($queue_name); - $queue_item_id = $queue->createItem($data); - - return $queue_item_id; - } - - - /** - * Check the authentication key sent in the header is valid. - * - * @return bool - */ - private function authenticate() { - $email_ops = new PostmarkOps(); - return $email_ops->checkAuth($this->request->getCurrentRequest()->headers->get("authorization")); - } - - /** - * Send email via Postmark API. - * - * @param array $emailFields - * The array containing Postmark API needed fieds. - */ - private function formatEmail(array &$emailFields) { - - // Create a nicer sender address if possible. - $emailFields["modified_from_address"] = $emailFields["from_address"]; - if (isset($emailFields["sender"])) { - $emailFields["modified_from_address"] = "{$emailFields["sender"]}<{$emailFields["from_address"]}>"; - } - - $emailFields["postmark_data"] = new CobEmail([ - "server" => $this->server, - ]); - - if (isset($this->template_class)) { - // This allows us to inject custom templates to reformat the email. - $this->template_class::formatOutboundEmail($emailFields); - } - - else { - // No class created to template the response. - // Create a default message for sending. - $cobdata = $emailFields["postmark_data"]; - $cobdata->setField("endpoint", $this::POSTMARK_DEFAULT_ENDPOINT); - $cobdata->setField("To", $emailFields["to_address"]); - $cobdata->setField("From", $emailFields["modified_from_address"]); - - if (isset($emailFields["template_id"])) { - $cobdata->setField("endpoint", "https://api.postmarkapp.com/email/withTemplate"); - $cobdata->setField("TemplateID", $emailFields["template_id"]); - $cobdata->setField("TemplateModel", [ - "Subject" => $emailFields["subject"], - "TextBody" => $emailFields["message"], - "ReplyTo" => $emailFields["from_address"] - ]); - $cobdata->delField("ReplyTo"); - $cobdata->delField("Subject"); - $cobdata->delField("TextBody"); - } - - else { - $cobdata->setField("Subject", $emailFields["subject"]); - $cobdata->setField("TextBody", $emailFields["message"]); - $cobdata->setField("ReplyTo", $emailFields["from_address"]); - $cobdata->delField("TemplateID"); - $cobdata->delField("TemplateModel"); - } - - if (!empty($emailFields['tag'])) { - $cobdata->setField("Tag", $emailFields['tag']); - } - - $emailFields["postmark_data"] = $cobdata; - - } - - // Remove empty fields here. - $emailFields["postmark_data"]->removeEmpty(); - - if ($this->debug) { - try { - $json = json_encode(@$emailFields["postmark_data"]->data()); - } - catch(\Exception $e) { - $json = "Error encountered {$e->getMessage} "; - if ($emailFields["postmark_data"]->hasValidationErrors()) { - $json .= implode(", ", $emailFields["postmark_data"]->getValidationErrors()); - } - } - \Drupal::logger("bos_email:PostmarkAPI") - ->info("Email prepped {$this->server}:
" . $json); - } - - // Validate the email data - $emailFields["postmark_data"]->validate(); - if ($emailFields["postmark_data"]->hasValidationErrors()) { - $this->error = implode(", ", $emailFields["postmark_data"]->getValidationErrors()); - return FALSE; - } - - return TRUE; - - } - - /** - * Send the email via Postmark. - * - * @param \Drupal\bos_email\CobEmail $mailobj The email object - * - * @return array - */ - private function sendEmail(CobEmail $email) { - - /** - * @var $mailobj CobEmail - */ - - // Extract the email object, and validate. - try { - $mailobj = $email->data(); - } - catch (\Exception $e) {} - - if ($email->hasValidationErrors()) { - return [ - 'status' => 'failed', - 'response' => implode(":", $email->getValidationErrors()), - ]; - - } - - // Send the email. - $email_ops = new PostmarkOps(); - $sent = $email_ops->sendEmail($mailobj); - - if (!$sent) { - // Add email data to queue because of Postmark failure. - $mailobj["postmark_error"] = $email_ops->error; - $this->addQueueItem($mailobj); - - if ($this->debug) { - \Drupal::logger("bos_email:PostmarkAPI")->info("Queued {$email->getField("server")}"); - } - - $response_message = self::MESSAGE_QUEUED; - - } - else { - // Message was sent successfully to sender via Postmark. - $response_message = self::MESSAGE_SENT; - } - - return [ - 'status' => 'success', - 'response' => $response_message, - ]; - - } - /** * Begin script and API operations when a session object has been secured. * @@ -297,18 +133,20 @@ private function sendEmail(CobEmail $email) { * The json response to send to the endpoint caller. */ public function beginSession(string $service) { + $token = new TokenOps(); $data = $this->request->getCurrentRequest()->get('email'); - $data_token = $token->tokenGet($data["token_session"]); + $data_token = $token->sessionTokenGet($data["token_session"]); if ($data_token["token_session"]) { // remove token session from DB to prevent reuse - $token->tokenRemove($data["token_session"]); + $token->sessionTokenRemove($data["token_session"]); + $this->authenticated = TRUE; // begin normal email submission return $this->begin($service); } else { - PostmarkOps::alertHandler($data, [], "", [], "sessiontoken"); + self::alertHandler($data, [], "", [], "sessiontoken"); return new CacheableJsonResponse([ 'status' => 'error', 'response' => 'invalid token', @@ -337,15 +175,16 @@ public function begin(string $service = 'contactform') { $service = ucwords($service); } - $this->server = $service; + $this->group_id = $service; if (class_exists("Drupal\\bos_email\\Templates\\{$service}") === TRUE) { $this->template_class = "Drupal\\bos_email\\Templates\\{$service}"; - $this->server = $this->template_class::getServerID(); + $this->group_id = $this->template_class::getGroupID(); $this->honeypot = $this->template_class::getHoneypotField() ?: ""; + $this->email_service = $this->template_class::getEmailService(); } if ($this->debug) { - \Drupal::logger("bos_email:PostmarkAPI")->info("Starts {$service}"); + Drupal::logger("bos_email:EmailController")->info("Starts {$service}"); } if ($this->request->getCurrentRequest()->getMethod() == "POST") { @@ -367,9 +206,17 @@ public function begin(string $service = 'contactform') { } } } + + if (empty($payload)) { + return new CacheableJsonResponse([ + 'status' => 'error', + 'response' => "Empty payload body.", + ], Response::HTTP_BAD_REQUEST); + } + // Check the honeypot if there is one. if (!empty($this->honeypot) && !empty($payload[$this->honeypot])) { - PostmarkOps::alertHandler($payload, [], "", [], "honeypot"); + self::alertHandler($payload, [], "", [], "honeypot"); return new CacheableJsonResponse([ 'status' => 'success', 'response' => str_replace(".", "!", self::MESSAGE_SENT), @@ -378,7 +225,7 @@ public function begin(string $service = 'contactform') { // Logging if ($this->debug) { - \Drupal::logger("bos_email:PostmarkAPI") + Drupal::logger("bos_email:EmailController") ->info("Set data {$service}:
" . json_encode($payload)); } @@ -387,27 +234,25 @@ public function begin(string $service = 'contactform') { unset($payload["token_session"]); } - if ($this->authenticate()) { + if (!$this->authenticated) { + $bearer_token = $this->request->getCurrentRequest()->headers->get("authorization") ?? ""; + $this->authenticated = TokenOps::checkBearerToken($bearer_token, $this->email_service); + } + + if ($this->authenticated) { // Format and validate the message body. if ($this->formatEmail($payload)) { // Send email. - if ($payload["postmark_data"]->is_scheduled()) { - $item = $payload["postmark_data"]->data(); - $id = $this->addSheduledItem($item); - if ($id && is_numeric($id)) { - $response_array = [ - 'status' => 'success', - 'response' => 'Message scheduled', - 'id' => $id - ]; - } + if ($payload["email_object"]->is_scheduled()) { + $item = $payload["email_object"]->data(); + return $this->addSheduledItem($item); } else { - $response_array = $this->sendEmail($payload["postmark_data"]); + $response_array = $this->sendEmail($payload["email_object"]); } } else { - PostmarkOps::alertHandler($payload, [], "", [], $this->error); + self::alertHandler($payload, [], "", [], $this->error); return new CacheableJsonResponse([ 'status' => 'error', 'response' => $this->error, @@ -416,7 +261,7 @@ public function begin(string $service = 'contactform') { } else { - PostmarkOps::alertHandler($payload, [], "", [], "authtoken"); + self::alertHandler($payload, [], "", [], "authtoken"); return new CacheableJsonResponse([ 'status' => 'error', 'response' => 'could not authenticate', @@ -433,7 +278,7 @@ public function begin(string $service = 'contactform') { // Logging if ($this->debug) { - \Drupal::logger("bos_email:PostmarkAPI") + Drupal::logger("bos_email:EmailController") ->info("Finished {$service}: " . json_encode($response_array)); } @@ -445,6 +290,54 @@ public function begin(string $service = 'contactform') { } } + /** + * Cancels a previously scheduled email. + * + * @param string $service The service name, passed from endpoint. + * + * @return \Drupal\Core\Cache\CacheableJsonResponse + * @throws \Exception + */ + public function cancelEmail(string $service): CacheableJsonResponse { + + $this->debug = str_contains($this->request->getCurrentRequest() + ->getHttpHost(), "lndo.site"); + + if ($payload = $this->request->getCurrentRequest()->getContent()) { + $payload = json_decode($payload, TRUE); + } + + $this->group_id = ucwords($service); + $this->email_service = new \Drupal\bos_email\Services\PostmarkService; + + if (empty($payload)) { + return new CacheableJsonResponse([ + 'status' => 'error', + 'response' => "Empty payload body.", + ], Response::HTTP_BAD_REQUEST); + } + + $bearer_token = $this->request->getCurrentRequest()->headers->get("authorization") ?? ""; + if (TokenOps::checkBearerToken($bearer_token, $this->email_service)) { + Drupal::database()->delete("queue") + ->condition("name", self::EMAIL_QUEUE_SCHEDULED, "=") + ->condition("item_id", $payload["id"], "=") + ->execute(); + + } + + else { + self::alertHandler($payload, [], "", [], "authtoken"); + return new CacheableJsonResponse([ + 'status' => 'error', + 'response' => 'could not authenticate', + ], Response::HTTP_UNAUTHORIZED); + } + + return new CacheableJsonResponse([], Response::HTTP_OK); + + } + /** * Handles incoming webhooks from PostMark. * @@ -715,7 +608,7 @@ public function callback(string $service, string $stream) { $this->debug = Boston::is_local(); if ($this->debug) { - \Drupal::logger("bos_email:PostmarkAPI")->info("Starts {$service} (callback)"); + Drupal::logger("bos_email:EmailController")->info("Starts {$service} (callback)"); } if ($this->request->getCurrentRequest()->getMethod() == "POST") { @@ -729,10 +622,10 @@ public function callback(string $service, string $stream) { $this->template_class = "Drupal\\bos_email\\Templates\\{$service}"; - $this->server = self::AUTORESPONDER_SERVERNAME; + $this->group_id = self::AUTORESPONDER_SERVERNAME; $this->stream = $stream; - $emailFields["postmark_data"] = new CobEmail([ - "server" => $this->server, + $emailFields["email_object"] = new CobEmail([ + "server" => $this->group_id, "endpoint" => self::POSTMARK_DEFAULT_ENDPOINT, "Tag" => $this->stream ]); @@ -741,14 +634,14 @@ public function callback(string $service, string $stream) { // Logging if ($this->debug) { - \Drupal::logger("bos_email:PostmarkAPI") + Drupal::logger("bos_email:EmailController") ->info("Set data {$service}:
" . json_encode($emailFields)); } - $response_array = $this->sendEmail($emailFields["postmark_data"]); + $response_array = $this->sendEmail($emailFields["email_object"]); if ($this->debug) { - \Drupal::logger("bos_email:PostmarkAPI") + Drupal::logger("bos_email:EmailController") ->info("Finished Callback {$service}: " . json_encode($response_array)); } @@ -765,6 +658,312 @@ public function callback(string $service, string $stream) { } + /**************************** + * HELPERS + ****************************/ + + /** + * Send email via Postmark API. + * + * @param array $emailFields + * The array containing Postmark API needed fieds. + */ + private function formatEmail(array &$emailFields) { + + // Create a nicer sender address if possible. + $emailFields["modified_from_address"] = $emailFields["from_address"]; + if (isset($emailFields["sender"])) { + $emailFields["modified_from_address"] = "{$emailFields["sender"]}<{$emailFields["from_address"]}>"; + } + + $emailFields["email_object"] = new CobEmail([ + "server" => $this->group_id, + "service" => $this->email_service::class, + ]); + + if (isset($this->template_class)) { + // This allows us to inject custom templates to reformat the email. + $this->template_class::formatOutboundEmail($emailFields); + } + + else { + // No class created to template the response. + // Create a default message for sending. + $cobdata = $emailFields["email_object"]; + $cobdata->setField("endpoint", $this::POSTMARK_DEFAULT_ENDPOINT); + $cobdata->setField("To", $emailFields["to_address"]); + $cobdata->setField("From", $emailFields["modified_from_address"]); + + if (isset($emailFields["template_id"])) { + $cobdata->setField("endpoint", "https://api.postmarkapp.com/email/withTemplate"); + $cobdata->setField("TemplateID", $emailFields["template_id"]); + $cobdata->setField("TemplateModel", [ + "Subject" => $emailFields["subject"], + "TextBody" => $emailFields["message"], + "ReplyTo" => $emailFields["from_address"] + ]); + $cobdata->delField("ReplyTo"); + $cobdata->delField("Subject"); + $cobdata->delField("TextBody"); + } + + else { + $cobdata->setField("Subject", $emailFields["subject"]); + $cobdata->setField("TextBody", $emailFields["message"]); + $cobdata->setField("ReplyTo", $emailFields["from_address"]); + $cobdata->delField("TemplateID"); + $cobdata->delField("TemplateModel"); + } + + if (!empty($emailFields['tag'])) { + $cobdata->setField("Tag", $emailFields['tag']); + } + + $emailFields["email_object"] = $cobdata; + + } + + // Remove empty fields here. + $emailFields["email_object"]->removeEmpty(); + + if ($this->debug) { + try { + $json = json_encode(@$emailFields["email_object"]->data()); + } + catch(\Exception $e) { + $json = "Error encountered {$e->getMessage} "; + if ($emailFields["email_object"]->hasValidationErrors()) { + $json .= implode(", ", $emailFields["email_object"]->getValidationErrors()); + } + } + Drupal::logger("bos_email:EmailController") + ->info("Email prepped {$this->group_id}:
" . $json); + } + + // Validate the email data + $emailFields["email_object"]->validate(); + if ($emailFields["email_object"]->hasValidationErrors()) { + $this->error = implode(", ", $emailFields["email_object"]->getValidationErrors()); + return FALSE; + } + + return TRUE; + + } + + /** + * Send the email via Postmark. + * + * @param \Drupal\bos_email\CobEmail $mailobj The email object + * + * @return array + */ + private function sendEmail(CobEmail $email) { + + /** + * @var $mailobj CobEmail + */ + + // Extract the email object, and validate. + try { + $mailobj = $email->data(); + } + catch (\Exception $e) {} + + if ($email->hasValidationErrors()) { + return [ + 'status' => 'failed', + 'response' => implode(":", $email->getValidationErrors()), + ]; + + } + + // Send the email. + try { + $sent = $this->email_service->sendEmail($mailobj); + } + catch (\Exception $e) { + Drupal::logger("bos_email:PostmarkService") + ->error($this->email_service->error); + $sent = FALSE; + } + + if (!$sent) { + + EmailController::alertHandler($mailobj, $this->email_service->response, $this->email_service->response["http_code"]); + Drupal::logger("bos_email:PostmarkService") + ->error($this->email_service->error); + + // Add email data to queue because of Postmark failure. + $mailobj["send_error"] = $this->email_service->error; + $this->addQueueItem($mailobj); + + Drupal::logger("bos_email:EmailController") + ->info("Queued {$email->getField("server")}"); + + $response_message = self::MESSAGE_QUEUED; + + } + else { + // Message was sent successfully to sender via Postmark. + $response_message = self::MESSAGE_SENT; + } + + return [ + 'status' => 'success', + 'response' => $response_message, + ]; + + } + + /** + * Load an email into the queue for later dispatch. + * + * @param array $data + * The array containing the email POST data. + */ + public function addQueueItem(array $data) { + + $queue = Drupal::queue(self::EMAIL_QUEUE); + $queue_item_id = $queue->createItem($data); + + return $queue_item_id; + } + + /** + * Load an email into the queue for later dispatch. + * + * @param array $data + * The array containing the email POST data. + */ + public function addSheduledItem(array $data) { + + // Quick bit of validation when an item is to be scheduled. + if (!is_numeric($data["senddatetime"])) { + return new CacheableJsonResponse([ + 'status' => 'error', + 'response' => 'Scheduled date is invalid.', + ], Response::HTTP_BAD_REQUEST); + } + elseif (intval($data["senddatetime"]) < strtotime("now")) { + return new CacheableJsonResponse([ + 'status' => 'error', + 'response' => 'Scheduled date is in the past.', + ], Response::HTTP_BAD_REQUEST); + } + + $queue = Drupal::queue(self::EMAIL_QUEUE_SCHEDULED); + $data["senddatetime"] = intval($data["senddatetime"]); + $data["send_date"] = date("D, d M Y H:i", $data["senddatetime"]); + $queue_item_id = $queue->createItem($data); + + if ($queue_item_id && is_numeric($queue_item_id)) { + return new CacheableJsonResponse([ + 'status' => 'success', + 'response' => 'Message scheduled', + 'id' => $queue_item_id + ], Response::HTTP_OK); + } + else { + return [ + 'status' => 'error', + 'response' => 'Could not schedule email.', + ]; + } + + } + + public static function alertHandler($item, $response, $http_code, $config = NULL, $error = NULL) { + + if (empty($config)) { + $config = Drupal::configFactory()->get("bos_email.settings"); + } + + $recipient = $config->get("alerts.recipient") ?? FALSE; + if ($recipient) { + + // Catch suppressed emails at PostMark + if ($config->get("hardbounce.hardbounce") + && isset($response["ErrorCode"]) + && strtolower($response["ErrorCode"]) == "406") { + $mailManager = Drupal::service('plugin.manager.mail'); + if ($config->get("hardbounce.recipient") ?? FALSE) { + // replace recipient with hardbounce recipient. + $recipient = $config->get("hardbounce.recipient"); + } + if (!$mailManager->mail("bos_email", 'hardbounce', $recipient, "en", array_merge($item, $response), NULL, TRUE)) { + Drupal::logger("bos_email:PostmarkService")->warning(t("Email sending from Drupal has failed.")); + } + } + + // When the token passed in the header is invalid + elseif ($config->get("alerts.token") + && $error + && strtolower($error) == "authtoken") { + $item["token_type"] = "Authentication Token"; + $mailManager = Drupal::service('plugin.manager.mail'); + if (!$mailManager->mail("bos_email", 'alerts.token', $recipient, "en", $item, NULL, TRUE)) { + Drupal::logger("bos_email:PostmarkService")->warning(t("Email sending from Drupal has failed.")); + } + } + + // When session token is invalid + elseif ($config->get("alerts.token") + && $error + && str_contains($error, "sessiontoken")) { + $item["token_type"] = "Session Token"; + $mailManager = Drupal::service('plugin.manager.mail'); + if (!$mailManager->mail("bos_email", 'alerts.token', $recipient, "en", $item, NULL, TRUE)) { + Drupal::logger("bos_email:PostmarkService")->warning(t("Email sending from Drupal has failed.")); + } + } + + // When the token needed by PostMark is invalid + elseif ($config->get("alerts.token") + && $error + && str_contains($error, "Cannot find token")) { + $item["token_type"] = "PostMark Server API Token"; + $mailManager = Drupal::service('plugin.manager.mail'); + if ($mailManager->mail("bos_email", 'alerts.token', $recipient, "en", $item, NULL, TRUE)) { + Drupal::logger("bos_email:PostmarkService")->warning(t("Email sending from Drupal has failed.")); + } + } + + // When the honeypot is not empty. + elseif ($config->get("alerts.honeypot") + && $error + && strtolower($error) == "honeypot") { + $mailManager = Drupal::service('plugin.manager.mail'); + if (!$mailManager->mail("bos_email", 'alerts.honeypot', $recipient, "en", $item, NULL, TRUE)) { + Drupal::logger("bos_email:PostmarkService")->warning(t("Email sending from Drupal has failed.")); + } + } + } + + // If no other issues, but the email failed to send. + if (!isset($mailManager) + && $config->get("monitor.all")) { + $recipient = $config->get("monitor.recipient") ?? FALSE; + if ($recipient) { + $mailManager = Drupal::service('plugin.manager.mail'); + if (!$mailManager->mail("bos_email", 'monitor.all', $recipient, "en", array_merge($item, $response), NULL, TRUE)) { + Drupal::logger("bos_email:PostmarkService") + ->warning(t("Email sending from Drupal has failed.")); + } + } + + } + + // Do dome logging if this is a local dev environment. + if (str_contains(Drupal::request()->getHttpHost(), "lndo.site")) { + Drupal::logger("bos_email:PostmarkService") + ->info(" + + +
Email" . json_encode($item) . "
Response" . json_encode($response ?? []) . "
HTTPCode{$http_code}
"); + } + } + protected function removeEmptyFields(CobEmail &$data): void { foreach($data as $key => $value) { if ($value == "") { diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkOps.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkOps.php deleted file mode 100644 index d9445561df..0000000000 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/PostmarkOps.php +++ /dev/null @@ -1,228 +0,0 @@ -getVars()["auth"]; - // Fetch the token from the posted form. - $post_token = explode("Token ",$post); - $quad_chunk = str_split($post_token[1], 4); - - foreach ($quad_chunk as $item) { - if (str_contains($token, $item)) { - array_push($matches, $item); - } - } - - return count(array_unique($matches)) == 15; - } - - /** - * Get vars for Postmark servers. - */ - public function getVars() { - - $postmark_env = []; - if (getenv('POSTMARK_SETTINGS')) { - $get_vars = explode(",", getenv('POSTMARK_SETTINGS')); - foreach ($get_vars as $item) { - $json = explode(":", $item); - if (!empty($json[0]) && !empty($json[1])) { - $postmark_env[$json[0]] = $json[1]; - } - } - } - else { - $postmark_env = Settings::get('postmark_settings') ?? []; - } - - return $postmark_env; - } - - /** - * Send email to Postmark. - */ - public function sendEmail($item) { - - // Check if we are sending out emails. - $config = \Drupal::configFactory()->get("bos_email.settings"); - if (!$config->get("enabled")) { - $this->error = "Emailing temporarily suspended for all PostMark emails"; - \Drupal::logger("bos_email:PostmarkOps")->error($this->error); - return FALSE; - } - elseif ($item["server"] && !$config->get(strtolower($item["server"]))["enabled"]) { - $this->error = "Emailing temporarily suspended for {$item["server"]} emails."; - \Drupal::logger("bos_email:PostmarkOps")->error($this->error); - return FALSE; - } - - // Send emails via Postmark API and cURL. - $item_json = json_encode($item); - - try { - $server_token = $item["server"] . "_token"; - if (empty($this->getVars()[$server_token])) { - throw new \Exception("Cannot find token for {$item['server']}"); - } - $server_token = $this->getVars()[$server_token]; - $headers = [ - "Accept: application/json", - "Content-Type: application/json", - "X-Postmark-Server-Token: " . $server_token, - ]; - if (\Drupal::request()->headers->has("X-PM-Bounce-Type")) { - $headers[] = "X-PM-Bounce-Type: " . \Drupal::request()->headers->get("X-PM-Bounce-Type"); - } - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $item["endpoint"]); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE); - curl_setopt($ch, CURLOPT_HEADER, FALSE); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); - curl_setopt($ch, CURLOPT_POSTFIELDS, $item_json); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - $response_json = curl_exec($ch); - - if (!$response_json) { - if ($e = curl_error($ch)) { - throw new \Exception("Error from Curl: {$e}
PAYLOAD:{$item_json}"); - } - else { - throw new \Exception("No Response from PostMark.
PAYLOAD:{$item_json}"); - } - } - $response = json_decode($response_json, TRUE); - - $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if ($http_code != 200) { - $headers = json_encode($headers); - throw new \Exception("Postmark Error {$http_code}
HEADERS: {$headers}
PAYLOAD: {$item_json}
RESPONSE:{$response_json}"); - } - - if (strtolower($response["ErrorCode"]) != "0") { - $headers = json_encode($headers); - throw new \Exception("Postmark Error Code: {$response['ErrorCode']}
HEADERS: {$headers}
PAYLOAD: {$item_json}
RESPONSE:{$response_json}"); - } - - return TRUE; - - } - catch (Exception $e) { - $this->error = $e->getMessage(); - $this->alertHandler($item, $response, $http_code, $config); - \Drupal::logger("bos_email:PostmarkOps")->error($e->getMessage()); - return FALSE; - } - } - - public static function alertHandler($item, $response, $http_code, $config, $error = NULL) { - - if (empty($config)) { - $config = \Drupal::configFactory()->get("bos_email.settings"); - } - - $recipient = $config->get("alerts.recipient") ?? FALSE; - if ($recipient) { - - // Catch suppressed emails at PostMark - if ($config->get("hardbounce.hardbounce") - && isset($response["ErrorCode"]) - && strtolower($response["ErrorCode"]) == "406") { - $mailManager = \Drupal::service('plugin.manager.mail'); - if ($config->get("hardbounce.recipient") ?? FALSE) { - // replace recipient with hardbounce recipient. - $recipient = $config->get("hardbounce.recipient"); - } - if (!$mailManager->mail("bos_email", 'hardbounce', $recipient, "en", array_merge($item, $response), NULL, TRUE)) { - \Drupal::logger("bos_email:PostmarkOps")->warning(t("Email sending from Drupal has failed.")); - } - } - - // When the token passed in the header is invalid - elseif ($config->get("alerts.token") - && $error - && strtolower($error) == "authtoken") { - $item["token_type"] = "Authentication Token"; - $mailManager = \Drupal::service('plugin.manager.mail'); - if (!$mailManager->mail("bos_email", 'alerts.token', $recipient, "en", $item, NULL, TRUE)) { - \Drupal::logger("bos_email:PostmarkOps")->warning(t("Email sending from Drupal has failed.")); - } - } - - // When session token is invalid - elseif ($config->get("alerts.token") - && $error - && str_contains($error, "sessiontoken")) { - $item["token_type"] = "Session Token"; - $mailManager = \Drupal::service('plugin.manager.mail'); - if (!$mailManager->mail("bos_email", 'alerts.token', $recipient, "en", $item, NULL, TRUE)) { - \Drupal::logger("bos_email:PostmarkOps")->warning(t("Email sending from Drupal has failed.")); - } - } - - // When the token needed by PostMark is invalid - elseif ($config->get("alerts.token") - && $error - && str_contains($error, "Cannot find token")) { - $item["token_type"] = "PostMark Server API Token"; - $mailManager = \Drupal::service('plugin.manager.mail'); - if ($mailManager->mail("bos_email", 'alerts.token', $recipient, "en", $item, NULL, TRUE)) { - \Drupal::logger("bos_email:PostmarkOps")->warning(t("Email sending from Drupal has failed.")); - } - } - - // When the honeypot is not empty. - elseif ($config->get("alerts.honeypot") - && $error - && strtolower($error) == "honeypot") { - $mailManager = \Drupal::service('plugin.manager.mail'); - if (!$mailManager->mail("bos_email", 'alerts.honeypot', $recipient, "en", $item, NULL, TRUE)) { - \Drupal::logger("bos_email:PostmarkOps")->warning(t("Email sending from Drupal has failed.")); - } - } - } - - // If no other issues, but the email failed to send. - if (!isset($mailManager) - && $config->get("monitor.all")) { - $recipient = $config->get("monitor.recipient") ?? FALSE; - if ($recipient) { - $mailManager = \Drupal::service('plugin.manager.mail'); - if (!$mailManager->mail("bos_email", 'monitor.all', $recipient, "en", array_merge($item, $response), NULL, TRUE)) { - \Drupal::logger("bos_email:PostmarkOps") - ->warning(t("Email sending from Drupal has failed.")); - } - } - - } - - // Do dome logging if this is a local dev environment. - if (str_contains(\Drupal::request()->getHttpHost(), "lndo.site")) { - \Drupal::logger("bos_email:PostmarkOps") - ->info(" - - -
Email" . json_encode($item) . "
Response" . json_encode($response ?? []) . "
HTTPCode{$http_code}
"); - } - } -} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/TokenOps.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/TokenOps.php deleted file mode 100644 index 710294aaac..0000000000 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/TokenOps.php +++ /dev/null @@ -1,60 +0,0 @@ -session = \Drupal::request()->getSession(); - } - - /** - * Creates a session token and saves in the session object. - * - * @return array The new Session Token just created. - */ - public function tokenCreate(): array { - $date_time = \Drupal::time()->getCurrentTime(); - $this->session->set("token_session_{$date_time}", $date_time); - return ['token_session' => $date_time]; - } - - /** - * Removes the token from the session object. - * - * @param string $data The session Token to remove. - * - * @return string[] Status. - */ - public function tokenRemove(string $data): array { - if ($this->session->remove("token_session_{$data}") !== null) { - return ['token_session' => "removed"]; - } - return ['token_session' => "not found"]; - } - - /** - * Returns an array with token_session indicating if it exists. - * This is usually used to check if a session token is valid. The field - * token_value is populated if the token is found. - * - * @param string $data The Session Token. - * - * @return array - */ - public function tokenGet(string $data): array { - if ($this->session->get("token_session_{$data}", FALSE)) { - return [ - 'token_session' => TRUE, - 'token_value' => "token_session_{$data}", - ]; - } - return ['token_session' => FALSE]; - } - -} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php new file mode 100644 index 0000000000..13d7a41fba --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php @@ -0,0 +1,22 @@ +configFactory->get(self::getEditableConfigNames()[0]); + + $service_options = [ + "DrupalService" => "Drupal", + "PostmarkService" => "Postmark" + ]; + $form["bos_email"] = [ '#type' => 'fieldset', '#title' => 'City of Boston Emailer', '#markup' => 'Fine-grain management for emails sent via City of Boston REST API.', "#tree" => TRUE, - "service" => [ - "#type" => "select", - '#title' => t('Current Email Service'), - '#description' => t('The Email Service which is currently being used.'), - "#options" => [ - "drupal" => "Drupal", - "postmark" => "Postmark" - ], - '#default_value' => $config->get('service') - ], - "enabled" => [ '#type' => 'checkbox', '#title' => t('Email Service Enabled'), @@ -132,6 +127,13 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#markup' => 'Emails from the main Contact Form - when clicking on email addresses on boston.gov.', '#collapsible' => FALSE, + "service" => [ + "#type" => "select", + '#title' => t('Contact Form Email Service'), + '#description' => t('The Email Service which is currently being used.'), + "#options" => $service_options, + '#default_value' => $config->get('contactform.service') + ], "enabled" => [ '#type' => 'checkbox', '#title' => t('Contact Form email service enabled'), @@ -151,6 +153,19 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#markup' => 'Emails from the Registry App - confirmations.', '#collapsible' => FALSE, + "service" => [ + "#type" => "select", + '#title' => t('Registry Email Service'), + '#description' => t('The Email Service which is currently being used.'), + "#options" => $service_options, + '#default_value' => $config->get('registry.service') + ], + "template" => [ + "#type" => "textfield", + '#title' => t('Default Registry Email Template'), + '#description' => t('The ID for the template being used -leave blank if no template is required.'), + '#default_value' => $config->get('registry.template') + ], "enabled" => [ '#type' => 'checkbox', '#title' => t('Registry email service enabled'), @@ -170,6 +185,13 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#markup' => 'Emails from the Commissions App.', '#collapsible' => FALSE, + "service" => [ + "#type" => "select", + '#title' => t('Commissions Email Service'), + '#description' => t('The Email Service which is currently being used.'), + "#options" => $service_options, + '#default_value' => $config->get('commissions.service') + ], "enabled" => [ '#type' => 'checkbox', '#title' => t('Commission email service enabled'), @@ -189,6 +211,13 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#markup' => 'Emails sent from Metrolist Listing Form processes.', '#collapsible' => FALSE, + "service" => [ + "#type" => "select", + '#title' => t('Metrolist Email Service'), + '#description' => t('The Email Service which is currently being used.'), + "#options" => $service_options, + '#default_value' => $config->get('metrolist.service') + ], "enabled" => [ '#type' => 'checkbox', '#title' => t('Metrolist email service enabled'), @@ -208,6 +237,19 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#markup' => 'Emails sent from Sanitation WebApp.', '#collapsible' => FALSE, + "service" => [ + "#type" => "select", + '#title' => t('Sanitation Email Service'), + '#description' => t('The Email Service which is currently being used.'), + "#options" => $service_options, + '#default_value' => $config->get('sanitation.service') + ], + "template" => [ + "#type" => "textfield", + '#title' => t('Default Sanitation Email Template'), + '#description' => t('The ID for the template being used -leave blank if no template is required.'), + '#default_value' => $config->get('sanitation.template') + ], "enabled" => [ '#type' => 'checkbox', '#title' => t('Sanitation email service enabled'), @@ -235,17 +277,23 @@ public function submitForm(array &$form, FormStateInterface $form_state) { if ($input = $form_state->getUserInput()["bos_email"]) { $this->configFactory->getEditable(self::getEditableConfigNames()[0]) - ->set("service", $input["service"]) ->set("enabled", $input["enabled"]) ->set("q_enabled", $input["q_enabled"]) + ->set("contactform.service", $input["contactform"]["service"]) ->set("contactform.enabled", $input["contactform"]["enabled"] ?? 0) ->set("contactform.q_enabled", $input["contactform"]["q_enabled"] ?? 0) + ->set("registry.service", $input["registry"]["service"]) + ->set("registry.template", $input["registry"]["template"]) ->set("registry.enabled", $input["registry"]["enabled"] ?? 0) ->set("registry.q_enabled", $input["registry"]["q_enabled"] ?? 0) + ->set("commissions.service", $input["commissions"]["service"]) ->set("commissions.enabled", $input["commissions"]["enabled"] ?? 0) ->set("commissions.q_enabled", $input["commissions"]["q_enabled"] ?? 0) + ->set("metrolist.service", $input["metrolist"]["service"]) ->set("metrolist.enabled", $input["metrolist"]["enabled"] ?? 0) ->set("metrolist.q_enabled", $input["metrolist"]["q_enabled"] ?? 0) + ->set("sanitation.service", $input["sanitation"]["service"]) + ->set("sanitation.template", $input["sanitation"]["template"]) ->set("sanitation.enabled", $input["sanitation"]["enabled"] ?? 0) ->set("sanitation.sched_enabled", $input["sanitation"]["sched_enabled"] ?? 0) ->set("sanitation.q_enabled", $input["sanitation"]["q_enabled"] ?? 0) diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php index ab0c6e9e57..c1ad970b8b 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php @@ -2,9 +2,10 @@ namespace Drupal\bos_email\Plugin\QueueWorker; +use Drupal\bos_email\Services\DrupalService; use Drupal\Core\Annotation\QueueWorker; use Drupal\Core\Queue\QueueWorkerBase; -use Drupal\bos_email\Controller\PostmarkOps; +use Exception; /** * Processes emails through Postmark API. @@ -36,15 +37,24 @@ public function processItem($item) { throw new \Exception("The queue for {$item["server"]} is paused by settings at /admin/config/system/boston/email_services."); } - if (!empty($item["postmark_error"])) { - unset($item["postmark_error"]); + if (!empty($item["send_error"])) { + unset($item["send_error"]); } - $email_ops = new PostmarkOps(); - $postmark_send = $email_ops->sendEmail($item); + if (!empty($item["service"])) { + try { + $email_ops = new $item["service"]; + } + catch (Exception $e) {} + } + + if (!isset($email_ops) || empty($email_ops)) { + // Defaults to Drupal. + $email_ops = new DrupalService(); + } - if (!$postmark_send) { - throw new \Exception("There was a problem in bos_email:PostmarkOps. {$email_ops->error}"); + if (!$email_ops->sendEmail($item)) { + throw new \Exception("There was a problem in {$email_ops::class}. {$email_ops->error}"); } } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php index 74a9cedcbb..11fe2e3e13 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php @@ -2,9 +2,12 @@ namespace Drupal\bos_email\Plugin\QueueWorker; +use Drupal\bos_email\Services\DrupalService; use Drupal\Core\Annotation\QueueWorker; +use Drupal\Core\Queue\DelayedRequeueException; use Drupal\Core\Queue\QueueWorkerBase; -use Drupal\bos_email\Controller\PostmarkOps; +use Drupal\Core\Queue\RequeueException; +use Exception; /** * Processes emails through Postmark API. @@ -23,7 +26,7 @@ class ScheduledEmailProcessor extends QueueWorkerBase { * @param mixed $item * The item stored in the queue. */ - public function processItem($item) { + public function processItem($item): void { try { $config = \Drupal::configFactory()->get("bos_email.settings"); @@ -36,20 +39,48 @@ public function processItem($item) { throw new \Exception("The queue for {$item["server"]} is paused by settings at /admin/config/system/boston/email_services."); } - if (!empty($item["postmark_error"])) { - unset($item["postmark_error"]); - } + if (intval($item["senddatetime"]) <= strtotime("Now")) { + if (!empty($item["senddatetime"])) { + unset($item["senddatetime"]); + } + if (!empty($item["send_date"])) { + unset($item["send_date"]); + } + + // load the correct email service. + if (!empty($item["service"])) { + try { + $email_ops = new $item["service"]; + } + catch (Exception $e) {} + } + + if (empty($email_ops)) { + // Defaults to Drupal so we can always send an email. + $email_ops = new DrupalService(); + } - if ($item["senddatetime"] <= strtotime("Now")) { - $email_ops = new PostmarkOps(); - $postmark_send = $email_ops->sendEmail($item); + // This will throw an error if the mail does not send, and will cause + // the item to remain in the queue. + $send = $email_ops->sendEmail($item); + + } + else { + // Delay the resending of this email until its scheduled time. + throw new DelayedRequeueException( + intval($item["senddatetime"]) - strtotime("Now"), + "Email scheduled for the future." + ); } - if (!$postmark_send) { - throw new \Exception("There was a problem in bos_email:PostmarkOps. {$email_ops->error}"); + if (!$send) { + throw new \Exception("There was a problem in bos_email:PostmarkService. {$email_ops->error}"); } } + catch (DelayedRequeueException $e) { + throw new DelayedRequeueException($e->getDelay(), $e->getMessage()); + } catch (\Exception $e) { \Drupal::logger("contactform")->error($e->getMessage()); throw new \Exception($e->getMessage()); diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php new file mode 100644 index 0000000000..19d898ada3 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php @@ -0,0 +1,91 @@ +service}.{$item["Tag"]}"; + + $mailManager = Drupal::service('plugin.manager.mail'); + + $sent = $mailManager->mail("bos_email", $key , $item["To"], "en", $item, NULL, TRUE); + + if (!$sent || !$sent["result"]) { + if (!empty($params["_error_message"])) { + throw new \Exception($params["_error_message"]); + } + else { + throw new \Exception("Error sending email."); + } + } + + $response_message = self::MESSAGE_SENT; + + } + catch (\Exception $e) { + try { + $this->addQueueItem($item); + } + catch (\Exception $ee) { + Drupal::logger("bos_email:DrupalService")->info("Failed to queued mail item in {$item->getField("server")}"); + return [ + 'status' => 'error', + 'response' => "Error sending message {$e->getMessage()}, then error queueing item {$ee->getMessage()}.", + ]; + } + + if (Boston::is_local()) { + Drupal::logger("bos_email:DrupalService")->info("Queued {$item->getField("server")}"); + } + + $response_message = self::MESSAGE_QUEUED; + } + + + return [ + 'status' => 'success', + 'response' => $response_message, + ]; + + } + + /** + * @inheritDoc + */ + public function getVars(): array { + // TODO: Implement getVars() method. + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php new file mode 100644 index 0000000000..12e6bb677a --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php @@ -0,0 +1,105 @@ +get("bos_email.settings"); + if (!$config->get("enabled")) { + $this->error = "Emailing temporarily suspended for all PostMark emails"; + \Drupal::logger("bos_email:PostmarkService")->error($this->error); + return FALSE; + } + elseif ($item["server"] && !$config->get(strtolower($item["server"]))["enabled"]) { + $this->error = "Emailing temporarily suspended for {$item["server"]} emails."; + \Drupal::logger("bos_email:PostmarkService")->error($this->error); + return FALSE; + } + + try { + $server_token = $item["server"] . "_token"; + $server_token = $this->getVars()[$server_token]; + if (empty($server_token)) { + throw new \Exception("Cannot find token for {$item['server']}"); + } + + $headers = [ + "Accept" => "application/json", + "Content-Type" => "application/json", + "X-Postmark-Server-Token" => $server_token, + ]; + if (\Drupal::request()->headers->has("X-PM-Bounce-Type")) { + $headers["X-PM-Bounce-Type"] = \Drupal::request()->headers->get("X-PM-Bounce-Type"); + } + + try { + $response = $this->post($item["endpoint"], $item, $headers); + } + catch (Exception $e) { + $headers = json_encode($this->request["headers"]); + $item = json_encode($item); + $response = json_encode($this->response); + $this->error = "Error posting to Postmark - {$e->getMessage()}"; + throw new \Exception("Posting Error {$this->response["http_code"]}
HEADERS: {$headers}
PAYLOAD: {$item}
RESPONSE:{$response}"); + } + + if (strtolower($response["ErrorCode"]) != "0") { + $headers = json_encode($headers); + $item = json_encode($item); + $response = json_encode($this->response); + $this->error = "Error code returned from Postmark - {$response["Message"]}"; + throw new \Exception("Return Error Code: {$response['ErrorCode']}
HEADERS: {$headers}
PAYLOAD: {$item}
RESPONSE:{$response}"); + } + + return TRUE; + + } + catch (Exception $e) { + $this->error = $e->getMessage(); + return FALSE; + } + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/TokenOps.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/TokenOps.php new file mode 100644 index 0000000000..69dfd2aba3 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/TokenOps.php @@ -0,0 +1,98 @@ +session = Drupal::request()->getSession(); + } + + /** + * Creates an expirable one-use token. The token will expire after 60 minutes. + * + * @return array The new Session Token just created. + */ + public function sessionTokenCreate(): array { + $date_time = Drupal::time()->getCurrentTime(); + Drupal::service("keyvalue.expirable") + ->get("client_rest_token") + ->setWithExpire("token_session_{$date_time}", $date_time, (60 * 60)); + // $this->session->set("token_session_{$date_time}", $date_time); + return ['token_session' => $date_time]; + } + + /** + * Removes the token from the session object. + * + * @param string $data The session Token to remove. + * + * @return string[] Status. + */ + public function sessionTokenRemove(string $data): array { + Drupal::service("keyvalue.expirable") + ->get("client_rest_token") + ->delete("token_session_{$data}"); +// if ($this->session->remove("token_session_{$data}") !== null) { +// if ($del) { + return ['token_session' => "removed"]; +// } +// return ['token_session' => "not found"]; + } + + /** + * Returns an array with token_session indicating if it exists. + * This is usually used to check if a session token is valid. The field + * token_value is populated if the token is found. + * + * @param string $data The Session Token. + * + * @return array + */ + public function sessionTokenGet(string $data): array { + $result = Drupal::service("keyvalue.expirable") + ->get("client_rest_token") + ->get("token_session_{$data}") ?? FALSE; + +// if ($this->session->get("token_session_{$data}", FALSE)) { + if ($result) { + return [ + 'token_session' => TRUE, + 'token_value' => "token_session_{$data}", + ]; + } + return ['token_session' => FALSE]; + + + } + + /** + * Check bearer token and authenticate. + */ + public static function checkBearerToken(string $bearer_token, EmailServiceInterface $email_service) { + + $matches = []; + // Read the auth key from settings. + $token = $email_service->getVars()["auth"]; + // Fetch the token from the posted form. + $post_token = explode("Token ",$bearer_token); + $quad_chunk = str_split($post_token[1], 4); + + foreach ($quad_chunk as $item) { + if (str_contains($token, $item)) { + array_push($matches, $item); + } + } + + return count(array_unique($matches)) == 15; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Contactform.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Contactform.php index b368e56968..6f77ddefc2 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Contactform.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Contactform.php @@ -3,7 +3,8 @@ namespace Drupal\bos_email\Templates; use Drupal\bos_email\CobEmail; -use Drupal\bos_email\Controller\PostmarkAPI; +use Drupal\bos_email\Controller\EmailController; +use Drupal\bos_email\EmailServiceInterface; use Drupal\bos_email\EmailTemplateBase; use Drupal\bos_email\EmailTemplateInterface; use Drupal\Component\Utility\Html; @@ -27,14 +28,14 @@ public static function formatOutboundEmail(array &$emailFields): void { /** * @var $cobdata CobEmail */ - $cobdata = &$emailFields["postmark_data"]; + $cobdata = &$emailFields["email_object"]; $cobdata->setField("Tag", "Contact Form"); if (isset($emailFields["endpoint"])) { $cobdata->addField("endpoint", "string", $emailFields["endpoint"]); } else { - $cobdata->addField("endpoint", "string", PostmarkAPI::POSTMARK_DEFAULT_ENDPOINT); + $cobdata->addField("endpoint", "string", EmailController::POSTMARK_DEFAULT_ENDPOINT); } self::templatePlainText($emailFields); @@ -65,7 +66,7 @@ public static function formatOutboundEmail(array &$emailFields): void { } else { // An email template is to be used. - $cobdata->setField("endpoint", PostmarkAPI::POSTMARK_TEMPLATE_ENDPOINT); + $cobdata->setField("endpoint", EmailController::POSTMARK_TEMPLATE_ENDPOINT); $cobdata->delField("TextBody"); $cobdata->delField("Subject"); $cobdata->delField("HtmlBody"); @@ -78,7 +79,7 @@ public static function formatOutboundEmail(array &$emailFields): void { */ public static function templatePlainText(&$emailFields): void { - $cobdata = &$emailFields["postmark_data"]; + $cobdata = &$emailFields["email_object"]; $msg = strip_tags($emailFields["message"]); if (empty($emailFields["TemplateID"]) && empty($emailFields["template_id"])) { @@ -113,7 +114,7 @@ public static function templateHtmlText(&$emailFields): void { if (empty($emailFields["TemplateID"]) && empty($emailFields["template_id"])) { - $cobdata = &$emailFields["postmark_data"]; + $cobdata = &$emailFields["email_object"]; $msg = Html::escape(Xss::filter($emailFields["message"])); $msg = str_replace("\n", "
", $msg); @@ -143,7 +144,7 @@ public static function formatInboundEmail(array &$emailFields): void { // if ($emailFields["endpoint"]->getField("server") == "contactform" // && str_contains($emailFields["OriginalRecipient"], "@web-inbound.boston.gov")) { -// $server = PostmarkAPI::AUTORESPONDER_SERVERNAME; +// $server = EmailController::AUTORESPONDER_SERVERNAME; // } // Find the original recipient @@ -152,14 +153,14 @@ public static function formatInboundEmail(array &$emailFields): void { /** * @var $cobdata CobEmail */ - $cobdata = &$emailFields["postmark_data"]; + $cobdata = &$emailFields["email_object"]; $original_recipient = $cobdata::decodeFakeEmail($emailFields["OriginalRecipient"]); $cobdata->setField("To", $original_recipient); $cobdata->setField("From", "contactform@boston.gov"); $cobdata->setField("Subject", $emailFields["Subject"]); $cobdata->setField("HtmlBody", $emailFields["HtmlBody"]); $cobdata->setField("TextBody", $emailFields["TextBody"]); - $cobdata->setField("endpoint", PostmarkAPI::POSTMARK_DEFAULT_ENDPOINT); + $cobdata->setField("endpoint", EmailController::POSTMARK_DEFAULT_ENDPOINT); // Select Headers $cobdata->processHeaders($emailFields["Headers"]); @@ -179,7 +180,17 @@ public static function getHoneypotField(): string { /** * @inheritDoc */ - public static function getServerID(): string { + public static function getEmailService(): EmailServiceInterface { + $config = \Drupal::service("config.factory")->get("bos_email.settings"); + $email_service = $config->get(self::getGroupID() . ".service"); + $email_service = "Drupal\\bos_email\\Services\\{$email_service}"; + return new $email_service; + } + + /** + * @inheritDoc + */ + public static function getGroupID(): string { return "contactform"; } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistInitiationForm.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistInitiationForm.php index 6776259094..69dff710d9 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistInitiationForm.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistInitiationForm.php @@ -3,7 +3,8 @@ namespace Drupal\bos_email\Templates; use Drupal\bos_email\CobEmail; -use Drupal\bos_email\Controller\PostmarkAPI; +use Drupal\bos_email\Controller\EmailController; +use Drupal\bos_email\EmailServiceInterface; use Drupal\bos_email\EmailTemplateInterface; use Drupal\bos_email\EmailTemplateBase; @@ -17,7 +18,7 @@ class MetrolistInitiationForm extends EmailTemplateBase implements EmailTemplate */ public static function templatePlainText(&$emailFields):void { - $cobdata = &$emailFields["postmark_data"]; + $cobdata = &$emailFields["email_object"]; $plain_text = trim($emailFields["message"]); $plain_text = html_entity_decode($plain_text); @@ -45,7 +46,7 @@ public static function templatePlainText(&$emailFields):void { */ public static function templateHtmlText(&$emailFields):void { - $cobdata = &$emailFields["postmark_data"]; + $cobdata = &$emailFields["email_object"]; $html = trim($emailFields["message"]); // Replace carriage returns with html line breaks @@ -101,10 +102,10 @@ public static function templateHtmlText(&$emailFields):void { */ public static function formatOutboundEmail(array &$emailFields): void { - $cobdata = &$emailFields["postmark_data"]; + $cobdata = &$emailFields["email_object"]; $cobdata->setField("Tag", "metrolist form initiation"); - $cobdata->setField("endpoint", $emailFields["endpoint"] ?? PostmarkAPI::POSTMARK_DEFAULT_ENDPOINT); + $cobdata->setField("endpoint", $emailFields["endpoint"] ?? EmailController::POSTMARK_DEFAULT_ENDPOINT); self::templatePlainText($emailFields); if (!empty($emailFields["useHtml"])) { @@ -158,10 +159,20 @@ public static function getHoneypotField(): string { /** * @inheritDoc */ - public static function getServerID(): string { + public static function getGroupID(): string { return "metrolist"; } + /** + * @inheritDoc + */ + public static function getEmailService(): EmailServiceInterface { + $config = \Drupal::service("config.factory")->get("bos_email.settings"); + $email_service = $config->get(self::getGroupID() . ".service"); + $email_service = "Drupal\\bos_email\\Services\\{$email_service}"; + return new $email_service; + } + /** * @inheritDoc */ diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingConfirmation.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingConfirmation.php index f8947aa9eb..379aac7f6d 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingConfirmation.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingConfirmation.php @@ -3,7 +3,8 @@ namespace Drupal\bos_email\Templates; use Drupal\bos_email\CobEmail; -use Drupal\bos_email\Controller\PostmarkAPI; +use Drupal\bos_email\Controller\EmailController; +use Drupal\bos_email\EmailServiceInterface; use Drupal\bos_email\EmailTemplateBase; use Drupal\bos_email\EmailTemplateInterface; @@ -17,7 +18,7 @@ class MetrolistListingConfirmation extends EmailTemplateBase implements EmailTem */ public static function templatePlainText(&$emailFields):void { - $cobdata = &$emailFields["postmark_data"]; + $cobdata = &$emailFields["email_object"]; $vars = self::_getRequestParams(); @@ -46,7 +47,7 @@ public static function templatePlainText(&$emailFields):void { */ public static function templateHtmlText(&$emailFields):void { - $cobdata = &$emailFields["postmark_data"]; + $cobdata = &$emailFields["email_object"]; $vars = self::_getRequestParams(); @@ -100,10 +101,10 @@ public static function templateHtmlText(&$emailFields):void { */ public static function formatOutboundEmail(array &$emailFields): void { - $cobdata = &$emailFields["postmark_data"]; + $cobdata = &$emailFields["email_object"]; $cobdata->setField("Tag", "metrolist confirmation"); - $cobdata->setField("endpoint", $emailFields["endpoint"] ?: PostmarkAPI::POSTMARK_DEFAULT_ENDPOINT); + $cobdata->setField("endpoint", $emailFields["endpoint"] ?: EmailController::POSTMARK_DEFAULT_ENDPOINT); self::templatePlainText($emailFields); if (!empty($emailFields["useHtml"])) { @@ -277,7 +278,17 @@ public static function getHoneypotField(): string { /** * @inheritDoc */ - public static function getServerID(): string { + public static function getEmailService(): EmailServiceInterface { + $config = \Drupal::service("config.factory")->get("bos_email.settings"); + $email_service = $config->get(self::getGroupID() . ".service"); + $email_service = "Drupal\\bos_email\\Services\\{$email_service}"; + return new $email_service; + } + + /** + * @inheritDoc + */ + public static function getGroupID(): string { return "metrolist"; } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingNotification.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingNotification.php index db8d09f5f3..37381cb1ce 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingNotification.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingNotification.php @@ -3,7 +3,8 @@ namespace Drupal\bos_email\Templates; use Drupal\bos_email\CobEmail; -use Drupal\bos_email\Controller\PostmarkAPI; +use Drupal\bos_email\Controller\EmailController; +use Drupal\bos_email\EmailServiceInterface; use Drupal\bos_email\EmailTemplateBase; use Drupal\bos_email\EmailTemplateInterface; @@ -17,7 +18,7 @@ class MetrolistListingNotification extends EmailTemplateBase implements EmailTem */ public static function templatePlainText(&$emailFields):void { - $cobdata = &$emailFields["postmark_data"]; + $cobdata = &$emailFields["email_object"]; $vars = self::_getRequestParams(); $decisions = ""; @@ -52,7 +53,7 @@ public static function templatePlainText(&$emailFields):void { */ public static function templateHtmlText(&$emailFields):void { - $cobdata = &$emailFields["postmark_data"]; + $cobdata = &$emailFields["email_object"]; $vars = self::_getRequestParams(); @@ -112,11 +113,11 @@ public static function templateHtmlText(&$emailFields):void { */ public static function formatOutboundEmail(array &$emailFields): void { - $cobdata = &$emailFields["postmark_data"]; + $cobdata = &$emailFields["email_object"]; $cobdata->setField("Tag", "metrolist notification"); - $cobdata->setField("endpoint", $emailFields["endpoint"] ?: PostmarkAPI::POSTMARK_DEFAULT_ENDPOINT); + $cobdata->setField("endpoint", $emailFields["endpoint"] ?: EmailController::POSTMARK_DEFAULT_ENDPOINT); self::templatePlainText($emailFields); if (!empty($emailFields["useHtml"])) { @@ -249,7 +250,17 @@ public static function getHoneypotField(): string { /** * @inheritDoc */ - public static function getServerID(): string { + public static function getEmailService(): EmailServiceInterface { + $config = \Drupal::service("config.factory")->get("bos_email.settings"); + $email_service = $config->get(self::getGroupID() . ".service"); + $email_service = "Drupal\\bos_email\\Services\\{$email_service}"; + return new $email_service; + } + + /** + * @inheritDoc + */ + public static function getGroupID(): string { return "metrolist"; } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Registry.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Registry.php index 6c9877613a..04b7875909 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Registry.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Registry.php @@ -3,7 +3,8 @@ namespace Drupal\bos_email\Templates; use Drupal\bos_email\CobEmail; -use Drupal\bos_email\Controller\PostmarkAPI; +use Drupal\bos_email\Controller\EmailController; +use Drupal\bos_email\EmailServiceInterface; use Drupal\bos_email\EmailTemplateBase; use Drupal\bos_email\EmailTemplateInterface; @@ -18,8 +19,8 @@ class Registry extends EmailTemplateBase implements EmailTemplateInterface { public static function formatOutboundEmail(array &$emailFields): void { /** @var $cobdata \Drupal\bos_email\CobEmail */ - $cobdata = &$emailFields["postmark_data"]; - $cobdata->setField("endpoint", PostmarkAPI::POSTMARK_TEMPLATE_ENDPOINT); + $cobdata = &$emailFields["email_object"]; + $cobdata->setField("endpoint", EmailController::POSTMARK_TEMPLATE_ENDPOINT); // Set up the Postmark template. $cobdata->setField("TemplateID", $emailFields["template_id"]); @@ -74,7 +75,18 @@ public static function getHoneypotField(): string { /** * @inheritDoc */ - public static function getServerID(): string { + public static function getEmailService(): EmailServiceInterface { + $config = \Drupal::service("config.factory")->get("bos_email.settings"); + $email_service = $config->get(self::getGroupID() . ".service"); + $email_service = "Drupal\\bos_email\\Services\\{$email_service}"; + return new $email_service; + } + + + /** + * @inheritDoc + */ + public static function getGroupID(): string { return "registry"; } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php index 28eff71628..bf0acdd0e5 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php @@ -3,9 +3,11 @@ namespace Drupal\bos_email\Templates; use Drupal\bos_email\CobEmail; -use Drupal\bos_email\Controller\PostmarkAPI; +use Drupal\bos_email\Controller\EmailController; +use Drupal\bos_email\EmailServiceInterface; use Drupal\bos_email\EmailTemplateBase; use Drupal\bos_email\EmailTemplateInterface; +use Drupal\bos_email\Services\PostmarkService; use Exception; /** @@ -19,17 +21,12 @@ class Sanitation extends EmailTemplateBase implements EmailTemplateInterface { public static function formatOutboundEmail(array &$emailFields): void { /** @var $cobdata \Drupal\bos_email\CobEmail */ - $cobdata = &$emailFields["postmark_data"]; - $cobdata->setField("endpoint", PostmarkAPI::POSTMARK_TEMPLATE_ENDPOINT); + $cobdata = &$emailFields["email_object"]; + $cobdata->setField("endpoint", EmailController::POSTMARK_TEMPLATE_ENDPOINT); // Set up the Postmark template. - $template_map = [ - "confirmation" => "sani_confirm", - "reminder1" => "sani_remind1", - "reminder2" => "sani_remind2", - "cancel" => "sani_cancel", - ]; - $cobdata->setField("TemplateID", $template_map[$emailFields["type"]]); + $template_id = \Drupal::config("bos_email.settings")->get("sanitation.template"); + $cobdata->setField("TemplateID", $template_id); $cobdata->setField("TemplateModel", [ "subject" => $emailFields["subject"], "TextBody" => $emailFields["message"], @@ -86,7 +83,17 @@ public static function getHoneypotField(): string { /** * @inheritDoc */ - public static function getServerID(): string { + public static function getEmailService(): EmailServiceInterface { + $config = \Drupal::service("config.factory")->get("bos_email.settings"); + $email_service = $config->get(self::getGroupID() . ".service"); + $email_service = "Drupal\\bos_email\\Services\\{$email_service}"; + return new $email_service; + } + + /** + * @inheritDoc + */ + public static function getGroupID(): string { return "sanitation"; } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/TestDrupalmail.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/TestDrupalmail.php index 704e9696db..99a8645ea5 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/TestDrupalmail.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/TestDrupalmail.php @@ -3,7 +3,7 @@ namespace Drupal\bos_email\Templates; use Drupal\bos_email\CobEmail; -use Drupal\bos_email\Controller\DrupalmailAPI; +use Drupal\bos_email\Controller\DrupalService; use Drupal\bos_email\EmailTemplateBase; use Drupal\bos_email\EmailTemplateInterface; @@ -61,7 +61,7 @@ public static function getHoneypotField(): string { /** * @inheritDoc */ - public static function getServerID(): string { + public static function getGroupID(): string { return "drupal_mail"; } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/tests/src/Functional/EmailControllerTest.php b/docroot/modules/custom/bos_components/modules/bos_email/tests/src/Functional/EmailControllerTest.php new file mode 100644 index 0000000000..a06ff083ac --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/tests/src/Functional/EmailControllerTest.php @@ -0,0 +1,66 @@ +client = new Client(); + } + + /** + * Tests bos_email.send route. + */ + public function testSendEmail() { + + $endpoint = "rest/email/sanitation"; + $data = []; + + $response = $this->client->post($endpoint, [ + "headers"=>['Content-type' => 'application/json'], + "body" => json_encode($data), + ]); + + // Assert the response status and other necessary aspects. + $this->assertEquals(200, $response->getStatusCode()); + + // Additional assertions. + } + + /** + * Send a GET request to an external API. + */ + public function getDataFromApi($endpoint) { + try { + $response = $this->client->get($endpoint); + $data = Json::decode($response->getBody()); + return $data; + } + catch (RequestException $e) { + watchdog_exception('my_module', $e); + } + + return []; + } +} From f565359ad9fb91095f5e448f0a924ce8f331d022 Mon Sep 17 00:00:00 2001 From: David Upton Date: Mon, 22 Apr 2024 15:04:59 -0400 Subject: [PATCH 03/48] DIG-4317 Fixes linting error. --- .../modules/bos_email/src/EmailServiceInterface.php | 6 ++++++ .../src/Plugin/QueueWorker/ContactformProcessItems.php | 2 +- .../src/Plugin/QueueWorker/ScheduledEmailProcessor.php | 3 +-- .../modules/bos_email/src/Services/DrupalService.php | 10 +++++++--- .../modules/bos_email/src/Services/PostmarkService.php | 8 +++++++- .../modules/bos_email/src/Templates/Sanitation.php | 2 -- 6 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php index 13d7a41fba..dd8b217cd0 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php @@ -4,6 +4,12 @@ interface EmailServiceInterface { + /** + * Returns the ID for this service. + * @return string + */ + public function id():string; + /** * Send email via the Service. * diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php index c1ad970b8b..abad4676cc 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php @@ -54,7 +54,7 @@ public function processItem($item) { } if (!$email_ops->sendEmail($item)) { - throw new \Exception("There was a problem in {$email_ops::class}. {$email_ops->error}"); + throw new \Exception("There was a problem in {$email_ops->id()}. {$email_ops->error}"); } } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php index 11fe2e3e13..7364b11d49 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php @@ -6,7 +6,6 @@ use Drupal\Core\Annotation\QueueWorker; use Drupal\Core\Queue\DelayedRequeueException; use Drupal\Core\Queue\QueueWorkerBase; -use Drupal\Core\Queue\RequeueException; use Exception; /** @@ -74,7 +73,7 @@ public function processItem($item): void { } if (!$send) { - throw new \Exception("There was a problem in bos_email:PostmarkService. {$email_ops->error}"); + throw new \Exception("There was a problem in {$email_ops->id()}. {$email_ops->error}"); } } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php index 19d898ada3..15aa959db7 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php @@ -4,10 +4,7 @@ use Boston; use Drupal; -use Drupal\bos_email\CobEmail; use Drupal\bos_email\EmailServiceInterface; -use Drupal\Core\Cache\CacheableJsonResponse; -use Symfony\Component\HttpFoundation\Response; /** * Postmark class for API. @@ -19,6 +16,13 @@ class DrupalService implements EmailServiceInterface { public null|string $error; + /** + * @inheritDoc + */ + public function id():string { + return "bos_email.DrupalService"; + } + /** * Send the email via Postmark. * diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php index 12e6bb677a..6da7f84405 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php @@ -3,7 +3,6 @@ namespace Drupal\bos_email\Services; use Drupal\bos_core\Controllers\Curl\BosCurlControllerBase; -use Drupal\bos_email\Controller\EmailController; use Drupal\bos_email\EmailServiceInterface; use Drupal\Core\Site\Settings; use Exception; @@ -18,6 +17,13 @@ class PostmarkService extends BosCurlControllerBase implements EmailServiceInter public array $response; + /** + * @inheritDoc + */ + public function id():string { + return "bos_email.PostmarkService"; + } + /** * Get vars for Postmark servers. */ diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php index bf0acdd0e5..1c597edb74 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php @@ -2,12 +2,10 @@ namespace Drupal\bos_email\Templates; -use Drupal\bos_email\CobEmail; use Drupal\bos_email\Controller\EmailController; use Drupal\bos_email\EmailServiceInterface; use Drupal\bos_email\EmailTemplateBase; use Drupal\bos_email\EmailTemplateInterface; -use Drupal\bos_email\Services\PostmarkService; use Exception; /** From 3a1f7abe8a99f26efa4c42c1ddaa3f37a16ab422 Mon Sep 17 00:00:00 2001 From: David Upton Date: Mon, 22 Apr 2024 16:09:50 -0400 Subject: [PATCH 04/48] DIG-4317 Removes invalid route. --- .../modules/bos_email/bos_email.routing.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml index 457e0abef6..51c0058619 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml @@ -79,15 +79,15 @@ bos_email.postmark.webhook: requirements: _access: 'TRUE' -bos_email.drupalmail.test: - path: '/rest/email/test/drupal/{service}/{tag}' - defaults: - _controller: '\Drupal\bos_email\Services\DrupalService::begin' - methods: [POST] - options: - no_cache: 'TRUE' - requirements: - _access: 'TRUE' +#bos_email.drupalmail.test: +# path: '/rest/email/test/drupal/{service}/{tag}' +# defaults: +# _controller: '\Drupal\bos_email\Services\DrupalService::begin' +# methods: [POST] +# options: +# no_cache: 'TRUE' +# requirements: +# _access: 'TRUE' bos_email.admin.services: path: '/admin/config/system/boston/email_services' From 2bb3ccdf2e033e94c6a3cc5313d3a81aab006ae1 Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 24 Apr 2024 17:18:09 -0400 Subject: [PATCH 05/48] DIG-4317 Adds Drupal Service, refactoring and tests. --- .../modules/bos_email/bos_email.http | 408 +++++++++++------- .../modules/bos_email/bos_email.module | 38 +- .../modules/bos_email/bos_email.routing.yml | 27 +- .../modules/bos_email/bos_email.services.yml | 25 ++ .../modules/bos_email/http-client.env.json | 11 +- .../modules/bos_email/src/CobEmail.php | 64 ++- .../src/Controller/EmailController.php | 258 ++++++----- .../bos_email/src/EmailProcessorInterface.php | 129 ++++++ .../bos_email/src/EmailServiceInterface.php | 14 + .../bos_email/src/EmailTemplateBase.php | 59 --- .../bos_email/src/EmailTemplateInterface.php | 75 ---- .../modules/bos_email/src/Form/ConfigForm.php | 193 ++------- .../src/Plugin/EmailProcessor/Commissions.php | 80 ++++ .../src/Plugin/EmailProcessor/Contactform.php | 207 +++++++++ .../Plugin/EmailProcessor/DefaultEmail.php | 100 +++++ .../EmailProcessor/EmailProcessorBase.php | 176 ++++++++ .../MetrolistInitiationForm.php | 125 +++--- .../MetrolistListingConfirmation.php | 78 +--- .../MetrolistListingNotification.php | 75 +--- .../src/Plugin/EmailProcessor/Registry.php | 121 ++++++ .../src/Plugin/EmailProcessor/Sanitation.php | 150 +++++++ .../bos_email/src/Services/DrupalService.php | 164 +++++-- .../src/Services/PostmarkService.php | 52 ++- .../bos_email/src/Templates/Contactform.php | 197 --------- .../bos_email/src/Templates/Registry.php | 100 ----- .../bos_email/src/Templates/Sanitation.php | 105 ----- .../src/Templates/TestDrupalmail.php | 68 --- .../templates/default.body.html.twig | 28 ++ .../includes/standard_footer.html.twig | 11 + .../includes/standard_head.html.twig | 25 ++ .../includes/standard_masthead.html.twig | 8 + .../mimemail-message--bos-email.html.twig | 72 ++++ .../templates/sanitation.body.html.twig | 29 ++ .../bos_core/src/Event/BosCoreFormEvent.php | 94 ++++ 34 files changed, 2062 insertions(+), 1304 deletions(-) create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/bos_email.services.yml create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/EmailProcessorInterface.php delete mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/EmailTemplateBase.php delete mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/EmailTemplateInterface.php create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Commissions.php create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Contactform.php create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/DefaultEmail.php create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/EmailProcessorBase.php rename docroot/modules/custom/bos_components/modules/bos_email/src/{Templates => Plugin/EmailProcessor}/MetrolistInitiationForm.php (50%) rename docroot/modules/custom/bos_components/modules/bos_email/src/{Templates => Plugin/EmailProcessor}/MetrolistListingConfirmation.php (74%) rename docroot/modules/custom/bos_components/modules/bos_email/src/{Templates => Plugin/EmailProcessor}/MetrolistListingNotification.php (73%) create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Registry.php create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Sanitation.php delete mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Contactform.php delete mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Registry.php delete mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php delete mode 100644 docroot/modules/custom/bos_components/modules/bos_email/src/Templates/TestDrupalmail.php create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/templates/default.body.html.twig create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/templates/includes/standard_footer.html.twig create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/templates/includes/standard_head.html.twig create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/templates/includes/standard_masthead.html.twig create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/templates/mimemail-message--bos-email.html.twig create mode 100644 docroot/modules/custom/bos_components/modules/bos_email/templates/sanitation.body.html.twig create mode 100644 docroot/modules/custom/bos_core/src/Event/BosCoreFormEvent.php diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http index f31caca1f6..cd43f10093 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http @@ -24,9 +24,11 @@ Authorization: Token {{bos_email_bearer_token}} ### # group: bos_email / session token # @name Use client-side token for Contact Form +# NOTE: email[contact] is the honeypot and must be present but empty. POST {{url}}/rest/email_session/contactform Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/x-www-form-urlencoded +Force-Service: {{ force_email_service }} email[token_session] = {{email_session_token}} & email[to_address] = {{email_test_recipient}} & @@ -51,7 +53,7 @@ email[contact] = client.assert(response.body.status == "success", "Response body.status is not 'success'"); }); client.test("Response message is 'Message Sent'", function () { - client.assert(response.body.response == "Message sent", "Response message is not 'Message sent'"); + client.assert(response.body.response == "Message sent.", "Response message is not 'Message sent.'"); }); %} @@ -62,6 +64,7 @@ POST {{url}}/rest/email/sanitation Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/json Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} { "to_address": "{{email_test_recipient}}", @@ -92,6 +95,7 @@ POST {{url}}/rest/email/sanitation Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/json Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} { "to_address": "{{email_test_recipient}}", @@ -99,7 +103,7 @@ Authorization: Token {{bos_email_bearer_token}} "subject": "Sanitation Confirmation", "message": "We are pleased to confirm your pickup", "type": "reminder1", - "senddatetime": "+1 week" + "senddatetime": "+5 minutes" } > {% @@ -122,11 +126,12 @@ Authorization: Token {{bos_email_bearer_token}} ### # group: bos_email / contact form -# @name Contact Form: Bearer Token +# @name Contact Form - Plain Text: Bearer Token POST {{url}}/rest/email/contactform Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: multipart/form-data; boundary=WebAppBoundary Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} --WebAppBoundary Content-Disposition: form-data; name="email[name]" @@ -160,32 +165,34 @@ Content-Disposition: form-data; name="email[useHtml]" 0 --WebAppBoundary -Content-Disposition: form-data; name="emai[contact]" +Content-Disposition: form-data; name="email[contact]" --WebAppBoundary > {% - // TODO: migrate to HTTP Client Response handler API - // pm.test("Status code is 200", function () { - // pm.response.to.have.status(200); - // }); - // pm.test("Status is 'Success'", function () { - // pm.expect(pm.response.text()).to.include("success"); - // }); - // pm.test("Response is 'Message Sent'", function () { - // pm.expect(pm.response.text()).to.include("Message sent"); - // }); + client.test("Status code is 200", function () { + client.assert(response.status ==200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "success", "Response status field is not 'success'"); + client.assert(response.body.response == "Message sent.", "Response message is not 'Message sent.'"); + }); %} ### # group: bos_email / contact form -# @name Contact Form HTML: Bearer Token +# @name Contact Form - HTML: Bearer Token POST {{url}}/rest/email/contactform Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/x-www-form-urlencoded Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} email[useHtml] = 1 & email[to_address] = {{email_test_recipient}} & @@ -201,16 +208,17 @@ email[browser] = PostmanRuntime/7.29.2 & email[contact] = > {% - // TODO: migrate to HTTP Client Response handler API - // pm.test("Status code is 200", function () { - // pm.response.to.have.status(200); - // }); - // pm.test("Status is 'Success'", function () { - // pm.expect(pm.response.text()).to.include("success"); - // }); - // pm.test("Response is 'Message Sent'", function () { - // pm.expect(pm.response.text()).to.include("Message sent"); - // }); + client.test("Status code is 200", function () { + client.assert(response.status ==200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "success", "Response status field is not 'success'"); + client.assert(response.body.response == "Message sent.", "Response message is not 'Message sent.'"); + }); %} ### @@ -219,6 +227,7 @@ email[contact] = POST {{url}}/rest/email/registry Content-Type: application/x-www-form-urlencoded Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} email[to_address] = {{email_test_recipient}} & email[from_address] = marriage@boston.gov & @@ -229,16 +238,17 @@ email[template_id] = {{registry_template_id}} & email[name] = Boston Resident > {% - // TODO: migrate to HTTP Client Response handler API - // pm.test("Status code is 200", function () { - // pm.response.to.have.status(200); - // }); - // pm.test("Status is 'Success'", function () { - // pm.expect(pm.response.text()).to.include("success"); - // }); - // pm.test("Response is 'Message Sent'", function () { - // pm.expect(pm.response.text()).to.include("Message sent"); - // }); + client.test("Status code is 200", function () { + client.assert(response.status ==200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "success", "Response status field is not 'success'"); + client.assert(response.body.response == "Message sent.", "Response message is not 'Message sent.'"); + }); %} ### @@ -248,6 +258,7 @@ POST {{url}}/rest/email/MetrolistInitiationForm Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: multipart/form-data; boundary=WebAppBoundary Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} --WebAppBoundary Content-Disposition: form-data; name="email[to_address]" @@ -280,16 +291,17 @@ Content-Disposition: form-data; name="email[useHtml]" --WebAppBoundary > {% - // TODO: migrate to HTTP Client Response handler API - // pm.test("Status code is 200", function () { - // pm.response.to.have.status(200); - // }); - // pm.test("Status is 'Success'", function () { - // pm.expect(pm.response.text()).to.include("success"); - // }); - // pm.test("Response is 'Message Sent'", function () { - // pm.expect(pm.response.text()).to.include("Message sent"); - // }); + client.test("Status code is 200", function () { + client.assert(response.status ==200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "success", "Response status field is not 'success'"); + client.assert(response.body.response == "Message sent.", "Response message is not 'Message sent.'"); + }); %} ### @@ -299,16 +311,17 @@ POST {{url}}/rest/email/MetrolistInitiationForm Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/json Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} { - "email[name]": "MetroList Listing", - "email[url]": "https:\/\/boston.lndo.site\/metrolist\/listing-request", - "email[from_address]": "noreply@boston.gov", - "email[sender]": "Metrolist Listing", - "email[to_address]": "david.upton@boston.gov", - "email[message]": "https:\/\/boston.lndo.site\/form\/metrolist-listing?token=ZeNCdhEQXryMmZXNzqKCrVpcXdKWsmaC4gNMF1tZzVo", - "email[useHtml]": "1", - "email[subject]": "Your requested MetroList Listing link", + "name": "MetroList Listing", + "url": "https:\/\/boston.lndo.site\/metrolist\/listing-request", + "from_address": "noreply@boston.gov", + "sender": "Metrolist Listing", + "to_address": "david.upton@boston.gov", + "message": "https:\/\/boston.lndo.site\/form\/metrolist-listing?token=ZeNCdhEQXryMmZXNzqKCrVpcXdKWsmaC4gNMF1tZzVo", + "useHtml": "1", + "subject": "Your requested MetroList Listing link", "hidden_name": "MetroList Listing", "hidden_subject": "Your requested MetroList Listing link", "hidden_message": "https:\/\/boston.lndo.site\/form\/metrolist-listing?token=ZeNCdhEQXryMmZXNzqKCrVpcXdKWsmaC4gNMF1tZzVo", @@ -316,16 +329,17 @@ Authorization: Token {{bos_email_bearer_token}} } > {% - // TODO: migrate to HTTP Client Response handler API - // pm.test("Status code is 200", function () { - // pm.response.to.have.status(200); - // }); - // pm.test("Status is 'Success'", function () { - // pm.expect(pm.response.text()).to.include("success"); - // }); - // pm.test("Response is 'Message Sent'", function () { - // pm.expect(pm.response.text()).to.include("Message sent"); - // }); + client.test("Status code is 200", function () { + client.assert(response.status ==200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "success", "Response status field is not 'success'"); + client.assert(response.body.response == "Message sent.", "Response message is not 'Message sent.'"); + }); %} ### @@ -335,6 +349,7 @@ POST {{url}}/rest/email/MetrolistListingConfirmation Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: multipart/form-data; boundary=WebAppBoundary Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} --WebAppBoundary Content-Disposition: form-data; name="email[to_address]" @@ -371,16 +386,17 @@ Davids Property --WebAppBoundary > {% - // TODO: migrate to HTTP Client Response handler API - // pm.test("Status code is 200", function () { - // pm.response.to.have.status(200); - // }); - // pm.test("Status is 'Success'", function () { - // pm.expect(pm.response.text()).to.include("success"); - // }); - // pm.test("Response is 'Message Sent'", function () { - // pm.expect(pm.response.text()).to.include("Message sent"); - // }); + client.test("Status code is 200", function () { + client.assert(response.status ==200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "success", "Response status field is not 'success'"); + client.assert(response.body.response == "Message sent.", "Response message is not 'Message sent.'"); + }); %} ### @@ -390,6 +406,7 @@ POST {{url}}/rest/email/MetrolistListingNotification Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: multipart/form-data; boundary=WebAppBoundary Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} --WebAppBoundary Content-Disposition: form-data; name="email[name]" @@ -434,16 +451,17 @@ Content-Disposition: form-data; name="new" --WebAppBoundary > {% - // TODO: migrate to HTTP Client Response handler API - // pm.test("Status code is 200", function () { - // pm.response.to.have.status(200); - // }); - // pm.test("Status is 'Success'", function () { - // pm.expect(pm.response.text()).to.include("success"); - // }); - // pm.test("Response is 'Message Sent'", function () { - // pm.expect(pm.response.text()).to.include("Message sent"); - // }); + client.test("Status code is 200", function () { + client.assert(response.status ==200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "success", "Response status field is not 'success'"); + client.assert(response.body.response == "Message sent.", "Response message is not 'Message sent.'"); + }); %} ### @@ -451,6 +469,7 @@ Content-Disposition: form-data; name="new" # @name Incoming Webhook (POSTMARK) POST {{url}}/rest/email/postmark/Contactform/inbound Content-Type: application/json +Force-Service: PostmarkService { "FromName": "Info", @@ -674,45 +693,17 @@ Content-Type: application/json } > {% - // TODO: migrate to HTTP Client Response handler API - // pm.test("Status code is 200", function () { - // pm.response.to.have.status(200); - // }); - // pm.test("Status is 'Success'", function () { - // pm.expect(pm.response.text()).to.include("success"); - // }); - // pm.test("Response is 'Message Sent'", function () { - // pm.expect(pm.response.text()).to.include("Message sent"); - // }); -%} - -### -# group: bos_email / Drupal mail -# @name Test Drupal Mail : : Bearer Token -POST {{url}}/rest/email/test/drupal/TestDrupalmail/plain -Content-Type: application/json -Authorization: Token {{bos_email_bearer_token}} - -{ - "To": "{{email_test_recipient}}", - "Cc": "", - "Bcc": "", - "Subject": "Postman: Test plaintext email", - "TextBody": "This is some text.\nThis is more text\\nThis is a link: https://www.mass.gov/covid19 ", - "Attachments": [] -} - -> {% - // TODO: migrate to HTTP Client Response handler API - // pm.test("Status code is 200", function () { - // pm.response.to.have.status(200); - // }); - // pm.test("Status is 'Success'", function () { - // pm.expect(pm.response.text()).to.include("success"); - // }); - // pm.test("Response is 'Message Sent'", function () { - // pm.expect(pm.response.text()).to.include("Message sent"); - // }); + client.test("Status code is 200", function () { + client.assert(response.status ==200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "success", "Response status field is not 'success'"); + client.assert(response.body.response == "Message sent.", "Response message is not 'Message sent.'"); + }); %} ### @@ -721,6 +712,7 @@ Authorization: Token {{bos_email_bearer_token}} POST {{url}}/rest/email_cancel/sanitation Content-Type: application/json Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} { "id": "{{sanitation_email_id}}" @@ -730,7 +722,6 @@ Authorization: Token {{bos_email_bearer_token}} client.test("Status code is 200", function () { client.assert(response.status == 200, "Response HTTP Code is not 200"); }); - client.global.clear("sanitation_email_id"); %} @@ -740,6 +731,7 @@ Authorization: Token {{bos_email_bearer_token}} POST {{url}}/rest/email_session/contactform Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/x-www-form-urlencoded +Force-Service: {{ force_email_service }} email[token_session] = bad-session-token & email[to_address] = {{email_test_recipient}} & @@ -771,6 +763,7 @@ POST {{url}}/rest/email/sanitation Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/json Authorization: Token asdfghjklqwertyui +Force-Service: {{ force_email_service }} { "to_address": "{{email_test_recipient}}", @@ -793,12 +786,113 @@ Authorization: Token asdfghjklqwertyui }); %} +### +# group: bos_email / fail-test +# @name Bad Scheduled Date: Bearer Token +POST {{url}}/rest/email/sanitation +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/json +Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} + +{ + "to_address": "{{email_test_recipient}}", + "from_address": "Sanitation ", + "subject": "Sanitation Confirmation", + "message": "We are pleased to confirm your pickup", + "type": "reminder1", + "senddatetime": "not a date" +} + +> {% + client.test("Status code is 400", function () { + client.assert(response.status == 400, "Response HTTP Code is not 400"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "error", "Response status field is not 'error'"); + client.assert(response.body.response == "Could not evaluate scheduled date.", "Response message is not 'Could not evaluate scheduled date.'"); + }); + client.test("Response contains email id", function () { + client.assert(typeof response.body.id !== "undefined" && response.body.id != "", "Response does not contain an ID field"); + client.global.set("sanitation_email_id", response.body.id); + }); +%} + +### +# group: bos_email / fail-test +# @name Past Scheduled Date: Bearer Token +POST {{url}}/rest/email/sanitation +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/json +Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} + +{ + "to_address": "{{email_test_recipient}}", + "from_address": "Sanitation ", + "subject": "Sanitation Confirmation", + "message": "We are pleased to confirm your pickup", + "type": "reminder1", + "senddatetime": "-1 week" +} + +> {% + client.test("Status code is 400", function () { + client.assert(response.status == 400, "Response HTTP Code is not 400"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "error", "Response status field is not 'error'"); + client.assert(response.body.response == "Scheduled date is in the past.", "Response message is not 'Scheduled date is in the past.'"); + }); +%} + +### +# group: bos_email / fail-test +# @name >400 day Scheduled Date: Bearer Token +POST {{url}}/rest/email/sanitation +Cookie: XDEBUG_SESSION=PHPSTORM +Content-Type: application/json +Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} + +{ + "to_address": "{{email_test_recipient}}", + "from_address": "Sanitation ", + "subject": "Sanitation Confirmation", + "message": "We are pleased to confirm your pickup", + "type": "reminder1", + "senddatetime": "+401 days" +} + +> {% + client.test("Status code is 400", function () { + client.assert(response.status == 400, "Response HTTP Code is not 400"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "error", "Response status field is not 'error'"); + client.assert(response.body.response == "Emails can only be scheduled up to 400 days in advance.", "Response message is not 'Emails can only be scheduled up to 400 days in advance.'"); + }); +%} + ### # group: bos_email / fail-test # @name Missing Bearer Token POST {{url}}/rest/email/contactform Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/x-www-form-urlencoded +Force-Service: {{ force_email_service }} email[to_address] = {{email_test_recipient}} & email[name] = Valid Email Recipient & @@ -810,26 +904,27 @@ email[browser] = PostmanRuntime/7.29.2 & email[contact] = > {% - // TODO: migrate to HTTP Client Response handler API - // pm.test("Status code is 401", function () { - // pm.response.to.have.status(401); - // }); - // pm.test("Status is 'error'", function () { - // pm.expect(pm.response.text()).to.include("error"); - // }); - // pm.test("Response is 'could not authenticate'", function () { - // pm.expect(pm.response.text()).to.include("could not authenticate"); - // }); + client.test("Status code is 401", function () { + client.assert(response.status == 401, "Response HTTP Code is not 401"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response - could not authenticate", function () { + client.assert(response.body.response == "could not authenticate", "Response status field is not 'could not authenticate'"); + }); %} ### # group: bos_email / fail-test -# @name Blocked User +# @name Blocked User: ONLY POSTMARK POST {{url}}/rest/email/contactform Cookie: XDEBUG_SESSION=PHPSTORM #X-PM-Bounce-Type: hardbounce Content-Type: application/x-www-form-urlencoded Authorization: Token {{bos_email_bearer_token}} +Force-Service: PostmarkService email[to_address] = blocked@boston.gov & email[name] = Blocked Email Recipient & @@ -841,16 +936,17 @@ email[browser] = PostmanRuntime/7.29.2 & email[contact] = > {% - // TODO: migrate to HTTP Client Response handler API - // pm.test("Status code is 200", function () { - // pm.response.to.have.status(200); - // }); - // pm.test("Status is 'success'", function () { - // pm.expect(pm.response.text()).to.include("success"); - // }); - // pm.test("Response is 'Message queued'", function () { - // pm.expect(pm.response.text()).to.include("Message queued"); - // }); + client.test("Status code is 200", function () { + client.assert(response.status ==200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "success", "Response status field is not 'success'"); + client.assert(response.body.response == "Message queued.", "Response message is not 'Message queued.'"); + }); %} ### @@ -860,6 +956,7 @@ POST {{url}}/rest/email/contactform Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/x-www-form-urlencoded Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} email[to_address] = {{email_test_recipient}} & email[name] = Test Person & @@ -871,16 +968,17 @@ email[browser] = PostmanRuntime/7.29.2 & email[contact] = should be empty > {% - // TODO: migrate to HTTP Client Response handler API - // pm.test("Status code is 200", function () { - // pm.response.to.have.status(200); - // }); - // pm.test("Status is 'error'", function () { - // pm.expect(pm.response.text()).to.include("success"); - // }); - // pm.test("Response is 'Message sent!'", function () { - // pm.expect(pm.response.text()).to.include("Message sent!"); - // }); + client.test("Status code is 200", function () { + client.assert(response.status ==200, "Response HTTP Code is not 200"); + }); + client.test("Response is json", function () { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + client.test("Expected Response format", function () { + client.assert(response.body.status == "success", "Response status field is not 'success'"); + client.assert(response.body.response == "Message sent!", "Response message is not 'Message sent!'"); + }); %} ### @@ -890,6 +988,7 @@ POST {{url}}/rest/email/sanitation Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/json Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} { "to_address": "bademail.com", @@ -920,6 +1019,7 @@ POST {{url}}/rest/email/sanitation Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/json Authorization: Token {{bos_email_bearer_token}} +Force-Service: {{ force_email_service }} { "to_address": "{{email_test_recipient}}", diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.module b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.module index a43d15a024..6e0e117a2d 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.module +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.module @@ -5,6 +5,27 @@ * The Base module file for bos_email module. */ +use Drupal\bos_email\Services\DrupalService; + +/** + * Implements hook_theme(). + */ +function bos_email_theme($existing, $type, $theme, $path) { + return [ + 'mimemail_message__bos_email' => [ + 'base hook' => 'mimemail_message', + 'template' => 'mimemail-message--bos-email', + ] + ]; +} + +/** + * Implements hook_preprocess_HOOK(). + */ +function bos_email_preprocess_mimemail_message(&$variables) { + return; +} + /** * Implements hook_mail(). */ @@ -45,24 +66,11 @@ function bos_email_mail($key, &$message, $params) { $message['headers']['Content-Type'] = 'text/html; charset=UTF-8; format=flowed; delsp=yes'; break; - case "TestDrupalmail.plain": default: - $message["from"] = $params["From"]; - $message["subject"] = $params["Subject"]; - if (!empty($params["HtmlBody"])) { - $message["body"] = [$params["HtmlBody"]]; - $params["plain"] = FALSE; - $params["plaintext"] = $params["TextBody"]; - } - else { - $message["body"] = []; - $params["plain"] = TRUE; - $params["plaintext"] = $params["TextBody"]; - } - + // This will handle templated emails. + DrupalService::renderEmail($params, $message); break; } - $params["To"] = empty($params["To"]) ? $params["to_address"] : $params["To"]; } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml index 51c0058619..1fff43b4b2 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.routing.yml @@ -4,7 +4,7 @@ bos_email.token: _controller: '\Drupal\bos_email\Controller\EmailController::token' methods: [POST] options: - no_cache: 'TRUE' + no_cache: TRUE requirements: _access: 'TRUE' @@ -14,7 +14,7 @@ bos_email.send: _controller: '\Drupal\bos_email\Controller\EmailController::begin' methods: [POST] options: - no_cache: 'TRUE' + no_cache: TRUE requirements: _access: 'TRUE' @@ -24,7 +24,7 @@ bos_email.cancel.scheduled: _controller: '\Drupal\bos_email\Controller\EmailController::cancelEmail' methods: [POST] options: - no_cache: 'TRUE' + no_cache: TRUE requirements: _access: 'TRUE' @@ -34,17 +34,16 @@ bos_email.send_token_session: _controller: '\Drupal\bos_email\Controller\EmailController::beginSession' methods: [POST] options: - no_cache: 'TRUE' + no_cache: TRUE requirements: _access: 'TRUE' - bos_email.send_legacy: path: '/emails' defaults: _controller: '\Drupal\bos_email\Controller\EmailController::begin' options: - no_cache: 'TRUE' + no_cache: TRUE methods: [POST] requirements: _access: 'TRUE' @@ -55,7 +54,7 @@ bos_email.newsletter_manager: _controller: '\Drupal\bos_email\Controller\UpakneeAPI::begin' methods: [POST] options: - no_cache: 'TRUE' + no_cache: TRUE requirements: _access: 'TRUE' @@ -65,7 +64,7 @@ bos_email.newsletter_manager_legacy: _controller: '\Drupal\bos_email\Controller\UpakneeAPI::begin' methods: [POST] options: - no_cache: 'TRUE' + no_cache: TRUE requirements: _access: 'TRUE' @@ -75,20 +74,10 @@ bos_email.postmark.webhook: _controller: '\Drupal\bos_email\Controller\EmailController::callback' methods: [POST] options: - no_cache: 'TRUE' + no_cache: TRUE requirements: _access: 'TRUE' -#bos_email.drupalmail.test: -# path: '/rest/email/test/drupal/{service}/{tag}' -# defaults: -# _controller: '\Drupal\bos_email\Services\DrupalService::begin' -# methods: [POST] -# options: -# no_cache: 'TRUE' -# requirements: -# _access: 'TRUE' - bos_email.admin.services: path: '/admin/config/system/boston/email_services' defaults: diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.services.yml b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.services.yml new file mode 100644 index 0000000000..7522bff84f --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.services.yml @@ -0,0 +1,25 @@ +services: + bos_email.configform.contactform: + class: Drupal\bos_email\Plugin\EmailProcessor\Contactform + tags: + - { name: 'event_subscriber' } + + bos_email.configform.registry: + class: Drupal\bos_email\Plugin\EmailProcessor\Registry + tags: + - { name: 'event_subscriber' } + + bos_email.configform.metrolist: + class: Drupal\bos_email\Plugin\EmailProcessor\MetrolistInitiationForm + tags: + - { name: 'event_subscriber' } + + bos_email.configform.sanitation: + class: Drupal\bos_email\Plugin\EmailProcessor\Sanitation + tags: + - { name: 'event_subscriber' } + + bos_email.configform.commissions: + class: Drupal\bos_email\Plugin\EmailProcessor\Commissions + tags: + - { name: 'event_subscriber' } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/http-client.env.json b/docroot/modules/custom/bos_components/modules/bos_email/http-client.env.json index 3e03ceb99b..7c1239a994 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/http-client.env.json +++ b/docroot/modules/custom/bos_components/modules/bos_email/http-client.env.json @@ -4,6 +4,15 @@ "bos_email_bearer_token": "c43517a240ee61898c00600eaa775aa0d0e639322c3f275b780f66062f69", "contact_form_session_token": "", "registry_template_id": "31135208", - "email_test_recipient": "david.upton@boston.gov" + "email_test_recipient": "david.upton@boston.gov", + "force_email_service": "DrupalService" + }, + "CI": { + "url": "https://d8-ci.boston.gov", + "bos_email_bearer_token": "c43517a240ee61898c00600eaa775aa0d0e639322c3f275b780f66062f69", + "contact_form_session_token": "", + "registry_template_id": "31135208", + "email_test_recipient": "david.upton@boston.gov", + "force_email_service": "DrupalService" } } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php b/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php index bd61f8da71..7a51a09df2 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php @@ -3,6 +3,7 @@ namespace Drupal\bos_email; use Drupal\Component\Utility\Xss; +use Exception; class CobEmail { @@ -170,7 +171,7 @@ public function validate(array $data = []):bool { } } - if (empty($data["TemplateID"])) { + if (empty($data["TemplateID"]) && empty($data["TemplateAlias"])) { // No template if (empty($data["HtmlBody"]) && empty($data["TextBody"])) { $this->validation_errors[] = "Must have Html or Text Body for email"; @@ -182,7 +183,7 @@ public function validate(array $data = []):bool { } } else { - // Using template + // Using template. if (empty($data["TemplateModel"]["TextBody"])) { $this->validation_errors[] = "Must have Text Body for templated email"; $validated = FALSE; @@ -194,6 +195,29 @@ public function validate(array $data = []):bool { } + if (isset($data["senddatetime"])) { + // Quick bit of validation when an item is to be scheduled. + if ($data["senddatetime"] === 0 || !is_numeric($data["senddatetime"])) { + // If the scheduled date === 0 then we could not parse a datetime from + // the string in the payload (in class fn setSendDate()). + // If it's not numeric then something went wrong somewhere. + $this->validation_errors[] = "Could not evaluate scheduled date."; + $validated = FALSE; + } + elseif (intval($data["senddatetime"]) < strtotime("now")) { + // Cannot schedule in the past. + $this->validation_errors[] = "Scheduled date is in the past."; + $validated = FALSE; + } + elseif (($data["senddatetime"] - strtotime("now")) > (400 * 24 * 60 * 60)) { + // Only allow emails to be scheduled 400 days in advance. + // Helps prevent date coding issues. + // (Still allows for an annual reminder for example). + $this->validation_errors[] = "Emails can only be scheduled up to 400 days in advance."; + $validated = FALSE; + } + } + return $validated; } @@ -354,7 +378,14 @@ public function addField(string $field, string $type, $value = ""): array { */ public function setField(string $field, $value = ""): array { - $this->data[$field] = $this->sanitizeField($field, $value); + if ($field == "senddatetime") { + // Force the senddatetime field through its own function which converts + // the value to a unix timestamp. + $this->setSendDate($value); + } + else { + $this->data[$field] = $this->sanitizeField($field, $value); + } return $this->data; @@ -566,7 +597,32 @@ public function removeEmpty() { * @return bool */ public function is_scheduled():bool { - return !empty($this->data["senddatetime"]); + return isset($this->data["senddatetime"]); + } + + /** + * Adds the scheduled time to the object as a valid UNIX timestamp, or 0 + * if it could not be evaluated. + * + * @param int|string $value a unix timestamp, or a valid date string + * + * @return void + */ + public function setSendDate(int|string $value) { + // Note: Setting 0 as the scheduled time will validate but will raise an + // error later. + try { + if (is_numeric($value)) { + $this->data["senddatetime"] = $this->sanitizeField("senddatetime", $value); + } + else { + $value = strtotime($value); + $this->data["senddatetime"] = $this->sanitizeField("senddatetime", $value ?: 0); + } + } + catch (Exception $e) { + $this->data["senddatetime"] = $this->sanitizeField("senddatetime", 0); + } } } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/EmailController.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/EmailController.php index 708198bca7..b7b8ae9512 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/EmailController.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/EmailController.php @@ -34,9 +34,6 @@ class EmailController extends ControllerBase { const MESSAGE_QUEUED = 'Message queued.'; - const POSTMARK_DEFAULT_ENDPOINT = 'https://api.postmarkapp.com/email'; - const POSTMARK_TEMPLATE_ENDPOINT = "https://api.postmarkapp.com/email/withTemplate"; - const AUTORESPONDER_SERVERNAME = 'autoresponder'; const EMAIL_QUEUE = "email_contactform"; @@ -67,13 +64,18 @@ class EmailController extends ControllerBase { private string $error = ""; - /** @var \Drupal\bos_email\EmailTemplateInterface */ - private $template_class; + /** @var \Drupal\bos_email\EmailProcessorInterface */ + private $email_processor; private string $honeypot; private bool $authenticated = FALSE; + /** + * @var string $stream The Callback stream ID + */ + private string $stream; + /** * Public construct for Request. */ @@ -167,21 +169,19 @@ public function begin(string $service = 'contactform') { $this->debug = str_contains($this->request->getCurrentRequest() ->getHttpHost(), "lndo.site"); - $response_array = []; - if (in_array($service, ["contactform", "registry", "sanitation"])) { - // This is done for legacy reasons (endpoint already in production and - // in lowercase) - $service = ucwords($service); - } + // Camelcase the service name. + $service = $this->endpointServiceToClass($service); - $this->group_id = $service; - if (class_exists("Drupal\\bos_email\\Templates\\{$service}") === TRUE) { - $this->template_class = "Drupal\\bos_email\\Templates\\{$service}"; - $this->group_id = $this->template_class::getGroupID(); - $this->honeypot = $this->template_class::getHoneypotField() ?: ""; - $this->email_service = $this->template_class::getEmailService(); + if (class_exists("Drupal\\bos_email\\Plugin\\EmailProcessor\\{$service}") === TRUE) { + $this->email_processor = "Drupal\\bos_email\\Plugin\\EmailProcessor\\{$service}"; } + else { + $this->email_processor = "Drupal\\bos_email\\Plugin\\EmailProcessor\\DefaultEmail"; + } + $this->group_id = $this->email_processor::getGroupID() ?? $service; + $this->honeypot = $this->email_processor::getHoneypotField() ?: ""; + $this->email_service = $this->email_processor::getEmailService($this->group_id); if ($this->debug) { Drupal::logger("bos_email:EmailController")->info("Starts {$service}"); @@ -189,23 +189,8 @@ public function begin(string $service = 'contactform') { if ($this->request->getCurrentRequest()->getMethod() == "POST") { - // Get the request payload. - if ($this->request->getCurrentRequest()->getContentTypeFormat() == "form") { - $payload = $this->request->getCurrentRequest()->get('email'); - } - elseif ($this->request->getCurrentRequest()->getContentTypeFormat() == "json") { - if ($_payload = $this->request->getCurrentRequest()->getContent()) { - $_payload = json_decode($_payload); - foreach ($_payload as $key => $value) { - if (str_contains($key, "email")) { - $payload[preg_replace('~email\[(.*)\]~', '$1', $key)] = $value; - } - else { - $payload[$key] = $value; - } - } - } - } + // Get the payload from the request. + $payload = $this->email_processor::fetchPayload($this->request->getCurrentRequest()); if (empty($payload)) { return new CacheableJsonResponse([ @@ -215,7 +200,8 @@ public function begin(string $service = 'contactform') { } // Check the honeypot if there is one. - if (!empty($this->honeypot) && !empty($payload[$this->honeypot])) { + if (!empty($this->honeypot) && + (!isset($payload[$this->honeypot]) || $payload[$this->honeypot] != "")) { self::alertHandler($payload, [], "", [], "honeypot"); return new CacheableJsonResponse([ 'status' => 'success', @@ -223,6 +209,15 @@ public function begin(string $service = 'contactform') { ], Response::HTTP_OK); } + // For testing, can force bos_email to use specified service. + $force = $this->request->getCurrentRequest()->headers->get("force_service", FALSE) ; + if ($force) { + $svs = "Drupal\\bos_email\\Services\\{$force}"; + if (class_exists($svs) === TRUE) { + $this->email_service = new $svs; + } + } + // Logging if ($this->debug) { Drupal::logger("bos_email:EmailController") @@ -234,24 +229,38 @@ public function begin(string $service = 'contactform') { unset($payload["token_session"]); } + // Check for authentication token if a session token was not already used. if (!$this->authenticated) { $bearer_token = $this->request->getCurrentRequest()->headers->get("authorization") ?? ""; $this->authenticated = TokenOps::checkBearerToken($bearer_token, $this->email_service); } if ($this->authenticated) { - // Format and validate the message body. - if ($this->formatEmail($payload)) { + + // Build the email object. + $email_object = new CobEmail([ + "server" => $this->group_id, + "service" => $this->email_service::class, + ]); + + // Customize the email object for this Processor. + $this->email_processor::parseEmailFields($payload, $email_object); + // Map the email fields in to object as needed for this email service. + $this->email_service->updateEmailObject($email_object); + + if ($this->verifyEmailObject($email_object)) { + // Send email. - if ($payload["email_object"]->is_scheduled()) { - $item = $payload["email_object"]->data(); + if ($email_object->is_scheduled()) { + $item = $email_object->data(); return $this->addSheduledItem($item); } else { - $response_array = $this->sendEmail($payload["email_object"]); + $response_array = $this->sendEmail($email_object); } } else { + $payload["email_object"] = $email_object; self::alertHandler($payload, [], "", [], $this->error); return new CacheableJsonResponse([ 'status' => 'error', @@ -304,11 +313,20 @@ public function cancelEmail(string $service): CacheableJsonResponse { ->getHttpHost(), "lndo.site"); if ($payload = $this->request->getCurrentRequest()->getContent()) { + // Always JSON. $payload = json_decode($payload, TRUE); } - $this->group_id = ucwords($service); - $this->email_service = new \Drupal\bos_email\Services\PostmarkService; + $service = $this->endpointServiceToClass($service); + if (class_exists("Drupal\\bos_email\\Plugin\\EmailProcessor\\{$service}") === TRUE) { + $this->email_processor = "Drupal\\bos_email\\Plugin\\EmailProcessor\\{$service}"; + } + else { + $this->email_processor = "Drupal\\bos_email\\Plugin\\EmailProcessor\\DefaultEmail"; + } + $this->group_id = $this->email_processor::getGroupID() ?? $service; + $this->honeypot = $this->email_processor::getHoneypotField() ?: ""; + $this->email_service = $this->email_processor::getEmailService($this->group_id); if (empty($payload)) { return new CacheableJsonResponse([ @@ -611,38 +629,56 @@ public function callback(string $service, string $stream) { Drupal::logger("bos_email:EmailController")->info("Starts {$service} (callback)"); } + $service = $this->endpointServiceToClass($service); + if ($this->request->getCurrentRequest()->getMethod() == "POST") { // Get the request payload. - $emailFields = $this->request->getCurrentRequest()->getContent(); - $emailFields = (array) json_decode($emailFields); + $payload = $this->request->getCurrentRequest()->getContent(); + $payload = json_decode($payload, TRUE); // Format the email message. - if (class_exists("Drupal\\bos_email\\Templates\\{$service}") === TRUE) { + if (class_exists("Drupal\\bos_email\\Plugin\\EmailProcessor\\{$service}") === TRUE) { - $this->template_class = "Drupal\\bos_email\\Templates\\{$service}"; + $this->email_processor = new ("Drupal\\bos_email\\Plugin\\EmailProcessor\\{$service}"); - $this->group_id = self::AUTORESPONDER_SERVERNAME; + $this->group_id = $this->email_processor->getGroupID(); $this->stream = $stream; - $emailFields["email_object"] = new CobEmail([ + $this->email_service = $this->email_processor::getEmailService($this->group_id); + $email_object = new CobEmail([ "server" => $this->group_id, - "endpoint" => self::POSTMARK_DEFAULT_ENDPOINT, + "service" => $this->email_service::class, + "endpoint" => $this->email_service::DEFAULT_ENDPOINT, "Tag" => $this->stream ]); - $this->template_class::formatInboundEmail($emailFields); + $this->email_processor::formatInboundEmail($payload, $email_object); + $this->email_service->updateEmailObject($email_object); // Logging if ($this->debug) { + $payload["email_object"] = $email_object; Drupal::logger("bos_email:EmailController") - ->info("Set data {$service}:
" . json_encode($emailFields)); + ->info("Set data {$service}:
" . json_encode($payload)); } - $response_array = $this->sendEmail($emailFields["email_object"]); + if ($this->verifyEmailObject($email_object)) { + + $response_array = $this->sendEmail($email_object); + + if ($this->debug) { + Drupal::logger("bos_email:EmailController") + ->info("Finished Callback {$service}: " . json_encode($response_array)); + } - if ($this->debug) { - Drupal::logger("bos_email:EmailController") - ->info("Finished Callback {$service}: " . json_encode($response_array)); + } + else { + $payload["email_object"] = $email_object; + self::alertHandler($payload, [], "", [], $this->error); + return new CacheableJsonResponse([ + 'status' => 'error', + 'response' => $this->error, + ], Response::HTTP_BAD_REQUEST); } } @@ -663,77 +699,24 @@ public function callback(string $service, string $stream) { ****************************/ /** - * Send email via Postmark API. + * Creates the email object which will be used by the email service. * * @param array $emailFields * The array containing Postmark API needed fieds. */ - private function formatEmail(array &$emailFields) { - - // Create a nicer sender address if possible. - $emailFields["modified_from_address"] = $emailFields["from_address"]; - if (isset($emailFields["sender"])) { - $emailFields["modified_from_address"] = "{$emailFields["sender"]}<{$emailFields["from_address"]}>"; - } - - $emailFields["email_object"] = new CobEmail([ - "server" => $this->group_id, - "service" => $this->email_service::class, - ]); - - if (isset($this->template_class)) { - // This allows us to inject custom templates to reformat the email. - $this->template_class::formatOutboundEmail($emailFields); - } - - else { - // No class created to template the response. - // Create a default message for sending. - $cobdata = $emailFields["email_object"]; - $cobdata->setField("endpoint", $this::POSTMARK_DEFAULT_ENDPOINT); - $cobdata->setField("To", $emailFields["to_address"]); - $cobdata->setField("From", $emailFields["modified_from_address"]); - - if (isset($emailFields["template_id"])) { - $cobdata->setField("endpoint", "https://api.postmarkapp.com/email/withTemplate"); - $cobdata->setField("TemplateID", $emailFields["template_id"]); - $cobdata->setField("TemplateModel", [ - "Subject" => $emailFields["subject"], - "TextBody" => $emailFields["message"], - "ReplyTo" => $emailFields["from_address"] - ]); - $cobdata->delField("ReplyTo"); - $cobdata->delField("Subject"); - $cobdata->delField("TextBody"); - } - - else { - $cobdata->setField("Subject", $emailFields["subject"]); - $cobdata->setField("TextBody", $emailFields["message"]); - $cobdata->setField("ReplyTo", $emailFields["from_address"]); - $cobdata->delField("TemplateID"); - $cobdata->delField("TemplateModel"); - } - - if (!empty($emailFields['tag'])) { - $cobdata->setField("Tag", $emailFields['tag']); - } - - $emailFields["email_object"] = $cobdata; - - } + private function verifyEmailObject(CobEmail $email_object) { // Remove empty fields here. - $emailFields["email_object"]->removeEmpty(); + $email_object->removeEmpty(); if ($this->debug) { try { - $json = json_encode(@$emailFields["email_object"]->data()); + $json = json_encode(@$email_object->data()); } catch(\Exception $e) { - $json = "Error encountered {$e->getMessage} "; - if ($emailFields["email_object"]->hasValidationErrors()) { - $json .= implode(", ", $emailFields["email_object"]->getValidationErrors()); + $json = "Error encountered {$e->getMessage()} "; + if ($email_object->hasValidationErrors()) { + $json .= implode(", ", $email_object->getValidationErrors()); } } Drupal::logger("bos_email:EmailController") @@ -741,9 +724,9 @@ private function formatEmail(array &$emailFields) { } // Validate the email data - $emailFields["email_object"]->validate(); - if ($emailFields["email_object"]->hasValidationErrors()) { - $this->error = implode(", ", $emailFields["email_object"]->getValidationErrors()); + $email_object->validate(); + if ($email_object->hasValidationErrors()) { + $this->error = implode(", ", $email_object->getValidationErrors()); return FALSE; } @@ -752,7 +735,7 @@ private function formatEmail(array &$emailFields) { } /** - * Send the email via Postmark. + * Send the email via Selected Email Service. * * @param \Drupal\bos_email\CobEmail $mailobj The email object * @@ -790,7 +773,7 @@ private function sendEmail(CobEmail $email) { if (!$sent) { - EmailController::alertHandler($mailobj, $this->email_service->response, $this->email_service->response["http_code"]); + EmailController::alertHandler($mailobj, $this->email_service->response(), $this->email_service->response()["http_code"]); Drupal::logger("bos_email:PostmarkService") ->error($this->email_service->error); @@ -838,20 +821,6 @@ public function addQueueItem(array $data) { */ public function addSheduledItem(array $data) { - // Quick bit of validation when an item is to be scheduled. - if (!is_numeric($data["senddatetime"])) { - return new CacheableJsonResponse([ - 'status' => 'error', - 'response' => 'Scheduled date is invalid.', - ], Response::HTTP_BAD_REQUEST); - } - elseif (intval($data["senddatetime"]) < strtotime("now")) { - return new CacheableJsonResponse([ - 'status' => 'error', - 'response' => 'Scheduled date is in the past.', - ], Response::HTTP_BAD_REQUEST); - } - $queue = Drupal::queue(self::EMAIL_QUEUE_SCHEDULED); $data["senddatetime"] = intval($data["senddatetime"]); $data["send_date"] = date("D, d M Y H:i", $data["senddatetime"]); @@ -972,4 +941,25 @@ protected function removeEmptyFields(CobEmail &$data): void { } } + private function endpointServiceToClass(string $service):string { + switch (strtolower($service)) { + case "metrolistinitiationform": + return "MetrolistInitiationForm"; + + case "metrolistlistingconfirmation": + return "MetrolistListingConfirmation"; + + case "metrolistlistingnotification": + return "MetrolistListingNotification"; + + case "commissions": + case "contactform": + case "registry": + case "sanitation": + default: + return ucwords($service); + } + + } + } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailProcessorInterface.php b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailProcessorInterface.php new file mode 100644 index 0000000000..0dcc94cbe5 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailProcessorInterface.php @@ -0,0 +1,129 @@ +addField but this + * should be an unusal circumstance. More commnly, when using a Template, we + * can set additional template arguments/parameters from the payload in this + * function by setting: + * $email_object->setField("TemplateModel", [assoc array of params]); + * + * @param array $payload The payload from the request + * @param CobEmail $email_object The structured email object used by mail services. + * + * @return void + */ + public static function parseEmailFields(array &$payload, CobEmail &$email_object): void; + + /** + * Read request and extract the payload - output as an associative array. + * Typically, the code set in the EmailProcessorBase class is sufficient. + * Overriding this function would allow you to extract the payload from a + * request format/type which is not supported in the base class. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * + * @return array + */ + public static function fetchPayload(Request $request): array; + + /** + * Creates a message body for incoming emails. + * + * @param array $emailFields An array containing the fields supplied from a + * Postmark webhook callback. + * + * @return void + */ + public static function formatInboundEmail(array $payload, CobEmail &$email_object): void; + + /** + * Creates a message body for plain text message. + * Adds/Updates field "TextBody" with formatted msg body to supplied array. + * + * @param array $emailFields An array containing the fields supplied from a + * form or the calling function needed by the template. + * @return void + */ + public static function templatePlainText(array &$payload, CobEmail &$email_object): void; + + /** + * Creates a message body for html message. + * Adds/Updates field "HtmlBody" with formatted msg body to supplied array. + * + * @param array $emailFields An array containing the fields supplied from a + * form or the calling function needed by the template. + * @return void + */ + public static function templateHtmlText(array &$payload, CobEmail &$email_object): void; + + /** + * Returns the payload field which is a honeypot for the form submitted. + * NOTE: Should return "" if there is no honeypot. + * + * @return string The name of the honeypot field on the form (if any). + * + */ + public static function getHoneypotField(): string; + + /** + * Return the correct email service to use to relay the email. + * Typically, the code set in the EmailProcessorBase class is sufficient + * (it reads the configuration settings from the configForm). + * Overriding this function would allow you to specifically define a + * service/class in a way not supported by the base class. + * @see buildForm + * @see submitForm + * + * @param string $group_id The group ID from this Processor. + * + * @return \Drupal\bos_email\EmailServiceInterface + */ + public static function getEmailService(string $group_id): EmailServiceInterface; + + /** + * Return a group id to use in this email service. + * + * @return string The name of the groupid. + * + * This is used throughout the app and controls which outbound email server is + * used in Postmark - There is a token in the ENVAR POSTMARK_SETTINGS + * ([server]_token) which directs the email to the correct postmark server. + * Other email services may require a similar concept. + */ + public static function getGroupID(): string; + + /** + * Inject configuration settings into config form. + * + * @param \Drupal\bos_core\Event\BosCoreFormEvent $event + * + * @return void + */ + public static function buildForm(BosCoreFormEvent $event): void; + + /** + * Save config values from config form + * + * @param \Drupal\bos_core\Event\BosCoreFormEvent $event + * + * @return void + */ + public static function submitForm(BosCoreFormEvent $event): void; + + /** + * Validate config form before it is saved. + * @param \Drupal\bos_core\Event\BosCoreFormEvent $event + * + * @return void + */ + public static function validateForm(BosCoreFormEvent $event): void; + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php index dd8b217cd0..9ab310a47e 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php @@ -4,12 +4,24 @@ interface EmailServiceInterface { + const DEFAULT_ENDPOINT = ''; + const TEMPLATE_ENDPOINT = ''; + /** * Returns the ID for this service. * @return string */ public function id():string; + /** + * Modify the email parameters for service requirements, e.g. templates. + * + * @param array $email_object + * + * @return void + */ + public function updateEmailObject(CobEmail &$email_object): void; + /** * Send email via the Service. * @@ -25,4 +37,6 @@ public function sendEmail(array $item): bool; */ public function getVars():array; + public function response(): array; + } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailTemplateBase.php b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailTemplateBase.php deleted file mode 100644 index 0e1935a4dc..0000000000 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailTemplateBase.php +++ /dev/null @@ -1,59 +0,0 @@ -configFactory->get(self::getEditableConfigNames()[0]); - $service_options = [ + $config = $this->configFactory->getEditable(self::getEditableConfigNames()[0]); + + $form["service_options"] = [ "DrupalService" => "Drupal", "PostmarkService" => "Postmark" ]; @@ -40,12 +42,14 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#title' => t('Email Service Enabled'), '#description' => t('When selected, emails will be sent via the indicated email service. When unselected all emails are added to the queue.'), '#default_value' => $config->get('enabled'), + '#weight' => -10, ], "q_enabled" => [ '#type' => 'checkbox', '#title' => t('Email-fail Queue Enabled'), '#description' => t('When selected, emails that the email service cannot process will be queued and there will be attempts to be resend. When unselected failed emails are discarded.'), '#default_value' => $config->get('q_enabled'), + '#weight' => -9 ], "alerts" => [ @@ -53,6 +57,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#title' => 'Email Service monitoring', '#description' => 'Configure internal alert emails for issues which arise during operations.', '#open' => FALSE, + '#weight' => -8, "conditions" => [ '#type' => 'fieldset', @@ -121,182 +126,28 @@ public function buildForm(array $form, FormStateInterface $form_state) { "footnote" => ['#markup' => "NOTE: These email alerts are sent via Drupal mail."], ], - "contactform" => [ - '#type' => 'fieldset', - '#title' => 'Contact Form', - '#markup' => 'Emails from the main Contact Form - when clicking on email addresses on boston.gov.', - '#collapsible' => FALSE, - - "service" => [ - "#type" => "select", - '#title' => t('Contact Form Email Service'), - '#description' => t('The Email Service which is currently being used.'), - "#options" => $service_options, - '#default_value' => $config->get('contactform.service') - ], - "enabled" => [ - '#type' => 'checkbox', - '#title' => t('Contact Form email service enabled'), - '#default_value' => $config->get('contactform.enabled'), - ], - "q_enabled" => [ - '#type' => 'checkbox', - '#title' => t('Contact Form queue processing enabled'), - '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), - '#default_value' => $config->get('contactform.q_enabled'), - ], - ], - - "registry" => [ - '#type' => 'fieldset', - '#title' => 'Registry Suite', - '#markup' => 'Emails from the Registry App - confirmations.', - '#collapsible' => FALSE, - - "service" => [ - "#type" => "select", - '#title' => t('Registry Email Service'), - '#description' => t('The Email Service which is currently being used.'), - "#options" => $service_options, - '#default_value' => $config->get('registry.service') - ], - "template" => [ - "#type" => "textfield", - '#title' => t('Default Registry Email Template'), - '#description' => t('The ID for the template being used -leave blank if no template is required.'), - '#default_value' => $config->get('registry.template') - ], - "enabled" => [ - '#type' => 'checkbox', - '#title' => t('Registry email service enabled'), - '#default_value' => $config->get('registry.enabled'), - ], - "q_enabled" => [ - '#type' => 'checkbox', - '#title' => t('Registry queue processing enabled'), - '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), - '#default_value' => $config->get('registry.q_enabled'), - ], - ], - - "commissions" => [ - '#type' => 'fieldset', - '#title' => 'Commissions App', - '#markup' => 'Emails from the Commissions App.', - '#collapsible' => FALSE, - - "service" => [ - "#type" => "select", - '#title' => t('Commissions Email Service'), - '#description' => t('The Email Service which is currently being used.'), - "#options" => $service_options, - '#default_value' => $config->get('commissions.service') - ], - "enabled" => [ - '#type' => 'checkbox', - '#title' => t('Commission email service enabled'), - '#default_value' => $config->get('commissions.enabled'), - ], - "q_enabled" => [ - '#type' => 'checkbox', - '#title' => t('Commissions queue processing enabled'), - '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), - '#default_value' => $config->get('commissions.q_enabled'), - ], - ], - - "metrolist" => [ - '#type' => 'fieldset', - '#title' => 'Metrolist Listing Form', - '#markup' => 'Emails sent from Metrolist Listing Form processes.', - '#collapsible' => FALSE, - - "service" => [ - "#type" => "select", - '#title' => t('Metrolist Email Service'), - '#description' => t('The Email Service which is currently being used.'), - "#options" => $service_options, - '#default_value' => $config->get('metrolist.service') - ], - "enabled" => [ - '#type' => 'checkbox', - '#title' => t('Metrolist email service enabled'), - '#default_value' => $config->get('metrolist.enabled'), - ], - "q_enabled" => [ - '#type' => 'checkbox', - '#title' => t('Metrolist queue processing enabled'), - '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), - '#default_value' => $config->get('metrolist.q_enabled'), - ], - ], + ]; - "sanitation" => [ - '#type' => 'fieldset', - '#title' => 'Sanitation Email Services', - '#markup' => 'Emails sent from Sanitation WebApp.', - '#collapsible' => FALSE, + // Dispatch an event to form listeners so that they can add their configs + // to this form. + $event = new BosCoreFormEvent($this->getFormId(), $form, $form_state, $config); + $dispatcher = \Drupal::service('event_dispatcher'); + $dispatcher->dispatch($event, BosCoreFormEvent::CONFIG_FORM_BUILD); - "service" => [ - "#type" => "select", - '#title' => t('Sanitation Email Service'), - '#description' => t('The Email Service which is currently being used.'), - "#options" => $service_options, - '#default_value' => $config->get('sanitation.service') - ], - "template" => [ - "#type" => "textfield", - '#title' => t('Default Sanitation Email Template'), - '#description' => t('The ID for the template being used -leave blank if no template is required.'), - '#default_value' => $config->get('sanitation.template') - ], - "enabled" => [ - '#type' => 'checkbox', - '#title' => t('Sanitation email service enabled'), - '#default_value' => $config->get('sanitation.enabled'), - ], - "q_enabled" => [ - '#type' => 'checkbox', - '#title' => t('Sanitation queue processing enabled'), - '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), - '#default_value' => $config->get('sanitation.q_enabled'), - ], - "sched_enabled" => [ - '#type' => 'checkbox', - '#title' => t('Sanitation scheduled email processing enabled'), - '#description' => t('When selected, scheduled emails are queued will be processed on each cron run.'), - '#default_value' => $config->get('sanitation.sched_enabled'), - ], - ], + $form = $event->getForm(); + unset($form["service_options"]); - ]; - return parent::buildForm($form, $form_state); + $form = parent::buildForm($form, $event->getFormState()); + return $form; } public function submitForm(array &$form, FormStateInterface $form_state) { if ($input = $form_state->getUserInput()["bos_email"]) { - $this->configFactory->getEditable(self::getEditableConfigNames()[0]) + $config = $this->configFactory->getEditable(self::getEditableConfigNames()[0]); + $config ->set("enabled", $input["enabled"]) ->set("q_enabled", $input["q_enabled"]) - ->set("contactform.service", $input["contactform"]["service"]) - ->set("contactform.enabled", $input["contactform"]["enabled"] ?? 0) - ->set("contactform.q_enabled", $input["contactform"]["q_enabled"] ?? 0) - ->set("registry.service", $input["registry"]["service"]) - ->set("registry.template", $input["registry"]["template"]) - ->set("registry.enabled", $input["registry"]["enabled"] ?? 0) - ->set("registry.q_enabled", $input["registry"]["q_enabled"] ?? 0) - ->set("commissions.service", $input["commissions"]["service"]) - ->set("commissions.enabled", $input["commissions"]["enabled"] ?? 0) - ->set("commissions.q_enabled", $input["commissions"]["q_enabled"] ?? 0) - ->set("metrolist.service", $input["metrolist"]["service"]) - ->set("metrolist.enabled", $input["metrolist"]["enabled"] ?? 0) - ->set("metrolist.q_enabled", $input["metrolist"]["q_enabled"] ?? 0) - ->set("sanitation.service", $input["sanitation"]["service"]) - ->set("sanitation.template", $input["sanitation"]["template"]) - ->set("sanitation.enabled", $input["sanitation"]["enabled"] ?? 0) - ->set("sanitation.sched_enabled", $input["sanitation"]["sched_enabled"] ?? 0) - ->set("sanitation.q_enabled", $input["sanitation"]["q_enabled"] ?? 0) ->set("alerts.recipient", $input["alerts"]["conditions"]["recipient"] ?? "") ->set("hardbounce.hardbounce", $input["alerts"]["hb"]["hardbounce"] ?? 0) ->set("hardbounce.recipient", $input["alerts"]["hb"]["recipient"] ?? "") @@ -308,7 +159,11 @@ public function submitForm(array &$form, FormStateInterface $form_state) { ->save(); } -// parent::submitForm($form, $form_state); + // Dispatch an event to form listeners so that they can save their configs + // to this form. + $event = new BosCoreFormEvent($this->getFormId(), $form, $form_state, $config); + $dispatcher = \Drupal::service('event_dispatcher'); + $dispatcher->dispatch($event, BosCoreFormEvent::CONFIG_FORM_SUBMIT); } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Commissions.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Commissions.php new file mode 100644 index 0000000000..e89497e0ae --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Commissions.php @@ -0,0 +1,80 @@ + 'buildForm', + BosCoreFormEvent::CONFIG_FORM_SUBMIT => 'submitForm', + ]; + } + + /** + * @inheritDoc + */ + public static function getGroupID(): string { + return "commissions"; + } + + /** + * @inheritDoc + */ + public static function buildForm(BosCoreFormEvent $event): void { + + if ($event->getEventType() == "bos_email_config_settings") { + $form = $event->getForm(); + $form["bos_email"]["commissions"] = [ + '#type' => 'fieldset', + '#title' => 'Commissions App', + '#markup' => 'Emails from the Commissions App.', + '#collapsible' => FALSE, + '#weight' => 4, + + "service" => [ + "#type" => "select", + '#title' => t('Commissions Email Service'), + '#description' => t('The Email Service which is currently being used.'), + "#options" => $form["service_options"], + '#default_value' => $event->getConfig('commissions.service') + ], + "enabled" => [ + '#type' => 'checkbox', + '#title' => t('Commission email service enabled'), + '#default_value' => $event->getConfig('commissions.enabled'), + ], + "q_enabled" => [ + '#type' => 'checkbox', + '#title' => t('Commissions queue processing enabled'), + '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), + '#default_value' => $event->getConfig('commissions.q_enabled'), + ], + ]; + + $event->setForm($form); + } + } + + /** + * @inheritDoc + */ + public static function submitForm(BosCoreFormEvent $event): void { + if ($event->getEventType() == "bos_email_config_settings") { + $input = $event->getFormState()->getUserInput()["bos_email"]; + $event->setConfig("commissions.service", $input["commissions"]["service"]); + $event->setConfig("commissions.enabled", $input["commissions"]["enabled"] ?? 0); + $event->setConfig("commissions.q_enabled", $input["commissions"]["q_enabled"] ?? 0); + } + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Contactform.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Contactform.php new file mode 100644 index 0000000000..5e1a2d568d --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Contactform.php @@ -0,0 +1,207 @@ + 'buildForm', + BosCoreFormEvent::CONFIG_FORM_SUBMIT => 'submitForm', + ]; + } + + /** + * Domain to be used as the sender. + */ + private const OUTBOUND_DOMAIN = "web-inbound.boston.gov"; + + /** + * @inheritDoc + */ + public static function parseEmailFields(array &$payload, CobEmail &$email_object): void { + + // Do the base email fields processing first. + parent::parseEmailFields($payload, $email_object); + + $email_object->setField("Tag", ($payload['tag'] ?? "Contact Form")); + + self::templatePlainText($payload, $email_object); + if (!empty($payload["useHtml"])) { + self::templateHtmlText($payload, $email_object); + } + + // Create a hash of the original poster's email + $hashemail = $email_object::encodeFakeEmail($payload["from_address"], self::OUTBOUND_DOMAIN ); + $email_object->setField("Metadata", [ + "opmail" => $email_object::hashText($payload["from_address"], $email_object::ENCODE) + ]); + $email_object->setField("From", "Boston.gov Contact Form <{$hashemail}>"); + + isset($payload["name"]) && $email_object->setField("ReplyTo", "{$payload["name"]}<{$payload["from_address"]}>"); + !empty($payload['headers']) && $email_object->setField("Headers", $payload['headers']); + empty($payload["TemplateID"]) && $email_object->setField("TemplateID", $payload["template_id"]); + + } + + /** + * @inheritDoc + */ + public static function templatePlainText(array &$payload, CobEmail &$email_object): void { + + $msg = strip_tags($payload["message"]); + + if (empty($payload["TemplateID"]) && empty($payload["template_id"])) { + $text = "-- REPLY ABOVE THIS LINE -- \n\n"; + $text .= "{$msg}\n\n"; + $text .= "{$payload["phone"]}\n\n"; + $text .= "-------------------------------- \n"; + $text .= "This message was sent using the contact form on Boston.gov."; + $text .= " It was sent by {$payload["name"]} from {$payload["from_address"]} and {$payload["phone"]}."; + $text .= " It was sent from {$payload["url"]}.\n\n"; + $text .= "-------------------------------- \n"; + $email_object->setField("TextBody", $text); + } + else { + // we are using a template + $email_object->delField("TextBody"); + $email_object->setField("TemplateID", $payload['TemplateID']); + $email_object->setField("TemplateModel", [ + "subject" => $payload["subject"], + "TextBody" => $msg, + "ReplyTo" => $payload["from_address"], + ]); + $payload["useHtml"] = 0; + } + + } + + /** + * @inheritDoc + */ + public static function templateHtmlText(array &$payload, CobEmail &$email_object): void { + + if (empty($payload["TemplateID"]) && empty($payload["template_id"])) { + + $msg = Html::escape(Xss::filter($payload["message"])); + $msg = str_replace("\n", "
", $msg); + + $html = "
----- REPLY ABOVE THIS LINE -----

"; + $html .= "
{$msg}
"; + $html .= "
"; + $html .= "{$payload["phone"]}"; + $html .= "
"; + $html .= ""; + $html .= ""; + $html .= "
This message was sent using the contact form on Boston.gov.
"; + $html .= " It was sent by {$payload["name"]} from {$payload["from_address"]} and {$payload["phone"]}.
"; + $html .= " It was sent from {$payload["url"]}.
"; + $html .= "
"; + + $email_object->setField("HtmlBody", $html); + + } + + } + + /** + * @inheritDoc + */ + public static function formatInboundEmail(array $payload, CobEmail &$email_object): void { + + $email_service = self::getEmailService(self::getGroupID()); + + // Create the email. + $original_recipient = $email_object::decodeFakeEmail($payload["OriginalRecipient"], self::OUTBOUND_DOMAIN); + $email_object->setField("To", $original_recipient); + $email_object->setField("From", "contactform@boston.gov"); + $email_object->setField("Subject", $payload["Subject"]); + $email_object->setField("HtmlBody", $payload["HtmlBody"]); + $email_object->setField("TextBody", $payload["TextBody"]); + $email_object->setField("endpoint", $email_service::DEFAULT_ENDPOINT); + // Select Headers + $email_object->processHeaders($payload["Headers"]); + + // Remove redundant fields + $email_object->delField("TemplateModel"); + $email_object->delField("TemplateID"); + + } + + /** + * @inheritDoc + */ + public static function getHoneypotField(): string { + return "contact"; + } + + /** + * @inheritDoc + */ + public static function getGroupID(): string { + return "contactform"; + } + + /** + * @inheritDoc + */ + public static function buildForm(BosCoreFormEvent $event): void { + + if ($event->getEventType() == "bos_email_config_settings") { + $form = $event->getForm(); + $form["bos_email"]["contactform"] = [ + '#type' => 'fieldset', + '#title' => 'Contact Form', + '#markup' => 'Emails from the main Contact Form - when clicking on email addresses on boston.gov.', + '#collapsible' => FALSE, + '#weight' => 0, + + "service" => [ + "#type" => "select", + '#title' => t('Contact Form Email Service'), + '#description' => t('The Email Service which is currently being used.'), + "#options" => $form["service_options"], + '#default_value' => $event->getConfig('contactform.service') + ], + "enabled" => [ + '#type' => 'checkbox', + '#title' => t('Contact Form email service enabled'), + '#default_value' => $event->getConfig('contactform.enabled'), + ], + "q_enabled" => [ + '#type' => 'checkbox', + '#title' => t('Contact Form queue processing enabled'), + '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), + '#default_value' => $event->getConfig('contactform.q_enabled'), + ], + ]; + + $event->setForm($form); + } + } + + /** + * @inheritDoc + */ + public static function submitForm(BosCoreFormEvent $event): void { + if ($event->getEventType() == "bos_email_config_settings") { + $input = $event->getFormState()->getUserInput()["bos_email"]; + $event->setConfig("contactform.service", $input["contactform"]["service"]); + $event->setConfig("contactform.enabled", $input["contactform"]["enabled"] ?? 0); + $event->setConfig("contactform.q_enabled", $input["contactform"]["q_enabled"] ?? 0); + } + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/DefaultEmail.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/DefaultEmail.php new file mode 100644 index 0000000000..89171f79f8 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/DefaultEmail.php @@ -0,0 +1,100 @@ +setField("TemplateModel", [assoc array of params]); + + } + + /** + * @inheritDoc + */ + public static function templatePlainText(array &$payload, CobEmail &$email_object): void { + + $msg = strip_tags($payload["message"]); + + if (empty($payload["TemplateID"]) && empty($payload["template_id"])) { + $text = "-- REPLY ABOVE THIS LINE -- \n\n"; + $text .= "{$msg}\n\n"; + $text .= "{$payload["phone"]}\n\n"; + $text .= "-------------------------------- \n"; + $text .= "This message was sent using the contact form on Boston.gov."; + $text .= " It was sent by {$payload["name"]} from {$payload["from_address"]} and {$payload["phone"]}."; + $text .= " It was sent from {$payload["url"]}.\n\n"; + $text .= "-------------------------------- \n"; + $email_object->setField("TextBody", $text); + } + else { + // we are using a template + $email_object->delField("TextBody"); + $email_object->setField("TemplateID", $payload['TemplateID']); + $email_object->setField("TemplateModel", [ + "subject" => $payload["subject"], + "TextBody" => $msg, + "ReplyTo" => $payload["from_address"], + ]); + $payload["useHtml"] = 0; + } + + } + + /** + * @inheritDoc + */ + public static function templateHtmlText(array &$payload, CobEmail &$email_object): void { + + if (empty($payload["TemplateID"]) && empty($payload["template_id"])) { + + $msg = Html::escape(Xss::filter($payload["message"])); + $msg = str_replace("\n", "
", $msg); + + $html = "
----- REPLY ABOVE THIS LINE -----

"; + $html .= "
{$msg}
"; + $html .= "
"; + $html .= "{$payload["phone"]}"; + $html .= "
"; + $html .= ""; + $html .= ""; + $html .= "
This message was sent using the contact form on Boston.gov.
"; + $html .= " It was sent by {$payload["name"]} from {$payload["from_address"]} and {$payload["phone"]}.
"; + $html .= " It was sent from {$payload["url"]}.
"; + $html .= "
"; + + $email_object->setField("HtmlBody", $html); + + } + + } + + /** + * @inheritDoc + */ + public static function getGroupID(): string { + return "default"; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/EmailProcessorBase.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/EmailProcessorBase.php new file mode 100644 index 0000000000..baeddebe85 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/EmailProcessorBase.php @@ -0,0 +1,176 @@ +get("bos_email.settings"); + $email_service = $config->get("{$group_id}.service"); + $email_service = "Drupal\\bos_email\\Services\\{$email_service}"; + return new $email_service; + } + + /** + * @inheritDoc + */ + public static function parseEmailFields(array &$payload, CobEmail &$email_object): void { + // Create a nicer sender address if possible. + if (isset($payload["sender"]) && isset($payload["from_address"])) { + $payload["modified_from_address"] = $payload["from_address"]; + $payload["modified_from_address"] = "{$payload["sender"]}<{$payload["from_address"]}>"; + } + // Try to map the payload fields into the mail_object. + $email_object->setField("Tag", ($payload['tag'] ?? "")); + $email_object->setField("To", ($payload["to_address"] ?? ($payload["to"] ?? ($payload["recipient"] ?? "")))); + $email_object->setField("From", ($payload["modified_from_address"] ?? ($payload["from_address"] ?? ""))); + $email_object->setField("TextBody", ($payload["message"] ?? ($payload["body"] ?? ""))); + $email_object->setField("ReplyTo", ($payload["reply_to"] ?? ($payload["from_address"] ?? ""))); + !empty($payload['subject']) && $email_object->setField("Subject", $payload["subject"]); + !empty($payload['cc']) && $email_object->setField("Cc", $payload['cc']); + !empty($payload['bcc']) && $email_object->setField("Bcc", $payload['bcc']); + !empty($payload['headers']) && $email_object->setField("Headers", $payload['headers']); + } + + /** + * Decodes the payload from the request into an associative array. + * + * @param string $payload + * + * @return void + */ + public static function fetchPayload(Request $request): array { + + if ($request->getContentTypeFormat() == "form") { + $_payload = $request->getPayload(); + if ($_payload->has("email")) { + $payload = $request->get("email"); + $_payload->remove("email"); + } + $payload = array_merge(($_payload->all() ?? []), ($payload ?? [])); + return $payload; + } + elseif ($request->getContentTypeFormat() == "json") { + if ($_payload = $request->getContent()) { + $_payload = json_decode($_payload, TRUE); + foreach ($_payload as $key => $value) { + if (str_contains($key, "email")) { + $payload[preg_replace('~email\[(.*)\]~', '$1', $key)] = $value; + } + else { + $payload[$key] = $value; + } + } + return $payload; + } + } + return []; + + } + + /** + * @inheritDoc + */ + public static function formatInboundEmail(array $payload, CobEmail &$email_object): void {} + + /** + * @inheritDoc + */ + public static function templatePlainText(array &$payload, CobEmail &$email_object): void {} + + /** + * @inheritDoc + */ + public static function templateHtmlText(array &$payload, CobEmail &$email_object): void {} + + /** + * @inheritDoc + */ + public static function getHoneypotField(): string { + return ""; + } + + /** + * @inheritDoc + */ + public static function getGroupID(): string { + return "default"; + } + + /** + * @inheritDoc + */ + public static function buildForm(BosCoreFormEvent $event): void {} + + /** + * @inheritDoc + */ + public static function submitForm(BosCoreFormEvent $event): void {} + + /** + * @inheritDoc + */ + public static function validateForm(BosCoreFormEvent $event): void {} + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistInitiationForm.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistInitiationForm.php similarity index 50% rename from docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistInitiationForm.php rename to docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistInitiationForm.php index 69dff710d9..1077ac1c21 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistInitiationForm.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistInitiationForm.php @@ -1,26 +1,32 @@ 'buildForm', + BosCoreFormEvent::CONFIG_FORM_SUBMIT => 'submitForm', + ]; + } - $cobdata = &$emailFields["email_object"]; + /** + * @inheritDoc + */ + public static function templatePlainText(array &$payload, CobEmail &$email_object):void { - $plain_text = trim($emailFields["message"]); + $plain_text = trim($payload["message"]); $plain_text = html_entity_decode($plain_text); // Replace html line breaks with carriage returns $plain_text = str_ireplace(["
", "

", ""], ["\n", "

\n", "\n"], $plain_text); @@ -32,32 +38,30 @@ public static function templatePlainText(&$emailFields):void { Metrolist Listing Form: {$plain_text} \n Questions? Feel free to email metrolist@boston.gov\n -------------------------------- -This message was requested from " . urldecode($emailFields['url']) . ". - The request was initiated by {$emailFields['to_address']}. +This message was requested from " . urldecode($payload['url']) . ". + The request was initiated by {$payload['to_address']}. -------------------------------- "; - $cobdata->setField("TextBody", $plain_text); + $email_object->setField("TextBody", $plain_text); } /** * @inheritDoc */ - public static function templateHtmlText(&$emailFields):void { + public static function templateHtmlText(array &$payload, CobEmail &$email_object):void { - $cobdata = &$emailFields["email_object"]; - - $html = trim($emailFields["message"]); + $html = trim($payload["message"]); // Replace carriage returns with html line breaks $html = str_ireplace(["\n", "\r\n"], ["
"], $html); - // $emailFields["message"] received the link url. We can change it into a + // $payload["message"] received the link url. We can change it into a // button here. $html = " Launch metrolist listing form "; - $form_url = urldecode($emailFields['url']); + $form_url = urldecode($payload['url']); $html = " \n @@ -73,7 +77,7 @@ public static function templateHtmlText(&$emailFields):void {

\n
\n

This message was requested from the Metrolist Listing service on Boston.gov.

\n -

The request was initiated by {$emailFields['to_address']}.

+

The request was initiated by {$payload['to_address']}.


\n "; @@ -93,37 +97,25 @@ public static function templateHtmlText(&$emailFields):void { "{$css}\n", $html); - $cobdata->setField("HtmlBody", $html); + $email_object->setField("HtmlBody", $html); } /** * @inheritDoc */ - public static function formatOutboundEmail(array &$emailFields): void { + public static function parseEmailFields(array &$payload, CobEmail &$email_object): void { - $cobdata = &$emailFields["email_object"]; - $cobdata->setField("Tag", "metrolist form initiation"); + // Do the base email fields processing first. + parent::parseEmailFields($payload, $email_object); - $cobdata->setField("endpoint", $emailFields["endpoint"] ?? EmailController::POSTMARK_DEFAULT_ENDPOINT); + $email_object->setField("Tag", "metrolist form initiation"); - self::templatePlainText($emailFields); - if (!empty($emailFields["useHtml"])) { - self::templateHtmlText($emailFields); + self::templatePlainText($payload, $email_object); + if (!empty($payload["useHtml"])) { + self::templateHtmlText($payload, $email_object); } - // Create a hash of the original poster's email - $cobdata->setField("To", $emailFields["to_address"]); - $cobdata->setField("From", $emailFields["from_address"]); - !empty($emailFields['cc']) && $cobdata->setField("Cc", $emailFields['cc']); - !empty($emailFields['bcc']) && $cobdata->setField("Bcc", $emailFields['bcc']); - $cobdata->setField("Subject", $emailFields["subject"]); - !empty($emailFields['headers']) && $cobdata->setField("Headers", $emailFields['headers']); - - // Remove redundant fields - $cobdata->delField("TemplateModel"); - $cobdata->delField("TemplateID"); - } /** @@ -148,14 +140,6 @@ public static function getCss():string { "; } - /** - * @inheritDoc - */ - public static function getHoneypotField(): string { - // TODO: Implement honeypot() method. - return ""; - } - /** * @inheritDoc */ @@ -166,18 +150,51 @@ public static function getGroupID(): string { /** * @inheritDoc */ - public static function getEmailService(): EmailServiceInterface { - $config = \Drupal::service("config.factory")->get("bos_email.settings"); - $email_service = $config->get(self::getGroupID() . ".service"); - $email_service = "Drupal\\bos_email\\Services\\{$email_service}"; - return new $email_service; + public static function buildForm(BosCoreFormEvent $event): void { + + if ($event->getEventType() == "bos_email_config_settings") { + $form = $event->getForm(); + $form["bos_email"]["metrolist"] = [ + '#type' => 'fieldset', + '#title' => 'Metrolist Listing Form', + '#markup' => 'Emails sent from Metrolist Listing Form processes.', + '#collapsible' => FALSE, + '#weight' => 2, + + "service" => [ + "#type" => "select", + '#title' => t('Metrolist Email Service'), + '#description' => t('The Email Service which is currently being used.'), + "#options" => $form["service_options"], + '#default_value' => $event->getConfig('metrolist.service') + ], + "enabled" => [ + '#type' => 'checkbox', + '#title' => t('Metrolist email service enabled'), + '#default_value' => $event->getConfig('metrolist.enabled'), + ], + "q_enabled" => [ + '#type' => 'checkbox', + '#title' => t('Metrolist queue processing enabled'), + '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), + '#default_value' => $event->getConfig('metrolist.q_enabled'), + ], + ]; + + $event->setForm($form); + } } /** * @inheritDoc */ - public static function formatInboundEmail(array &$emailFields): void { - // TODO: Implement incoming() method. + public static function submitForm(BosCoreFormEvent $event): void { + if ($event->getEventType() == "bos_email_config_settings") { + $input = $event->getFormState()->getUserInput()["bos_email"]; + $event->setConfig("metrolist.service", $input["metrolist"]["service"]); + $event->setConfig("metrolist.enabled", $input["metrolist"]["enabled"] ?? 0); + $event->setConfig("metrolist.q_enabled", $input["metrolist"]["q_enabled"] ?? 0); + } } } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingConfirmation.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingConfirmation.php similarity index 74% rename from docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingConfirmation.php rename to docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingConfirmation.php index 379aac7f6d..7351dfdb9b 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingConfirmation.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingConfirmation.php @@ -1,24 +1,19 @@ setField("TextBody", $text); + $email_object->setField("TextBody", $text); } /** * @inheritDoc */ - public static function templateHtmlText(&$emailFields):void { - - $cobdata = &$emailFields["email_object"]; + public static function templateHtmlText(array &$payload, CobEmail &$email_object):void { $vars = self::_getRequestParams(); @@ -86,43 +79,31 @@ public static function templateHtmlText(&$emailFields):void {

\n
\n

This message was sent after completing the Metrolist Listing form on Boston.gov.

\n -

The form was submitted by {$emailFields['name']} ({$emailFields['to_address']}) from the page at " . urldecode($emailFields['url']) . ".

+

The form was submitted by {$payload['name']} ({$payload['to_address']}) from the page at " . urldecode($payload['url']) . ".


\n "; - $html = self::_makeHtml($html, $emailFields["subject"]); + $html = self::_makeHtml($html, $payload["subject"]); - $cobdata->setField("HtmlBody", $html); + $email_object->setField("HtmlBody", $html); } /** * @inheritDoc */ - public static function formatOutboundEmail(array &$emailFields): void { + public static function parseEmailFields(array &$payload, CobEmail &$email_object): void { - $cobdata = &$emailFields["email_object"]; - $cobdata->setField("Tag", "metrolist confirmation"); + // Do the base email fields processing first. + parent::parseEmailFields($payload, $email_object); - $cobdata->setField("endpoint", $emailFields["endpoint"] ?: EmailController::POSTMARK_DEFAULT_ENDPOINT); + $email_object->setField("Tag", "metrolist confirmation"); - self::templatePlainText($emailFields); - if (!empty($emailFields["useHtml"])) { - self::templateHtmlText($emailFields); + self::templatePlainText($payload, $email_object); + if (!empty($payload["useHtml"])) { + self::templateHtmlText($payload, $email_object); } - // Create a hash of the original poster's email - $cobdata->setField("To", $emailFields["to_address"]); - $cobdata->setField("From", $emailFields["from_address"]); - !empty($emailFields['cc']) && $cobdata->setField("Cc", $emailFields['cc']); - !empty($emailFields['bcc']) && $cobdata->setField("Bcc", $emailFields['bcc']); - $cobdata->setField("Subject", $emailFields["subject"]); - !empty($emailFields['headers']) && $cobdata->setField("Headers", $emailFields['headers']); - - // Remove redundant fields - $cobdata->delField("TemplateModel"); - $cobdata->delField("TemplateID"); - } /** @@ -267,24 +248,6 @@ public static function _makeHtml(string $html, string $title) { } - /** - * @inheritDoc - */ - public static function getHoneypotField(): string { - // TODO: Implement honeypot() method. - return ""; - } - - /** - * @inheritDoc - */ - public static function getEmailService(): EmailServiceInterface { - $config = \Drupal::service("config.factory")->get("bos_email.settings"); - $email_service = $config->get(self::getGroupID() . ".service"); - $email_service = "Drupal\\bos_email\\Services\\{$email_service}"; - return new $email_service; - } - /** * @inheritDoc */ @@ -292,11 +255,4 @@ public static function getGroupID(): string { return "metrolist"; } - /** - * @inheritDoc - */ - public static function formatInboundEmail(array &$emailFields): void { - // TODO: Implement incoming() method. - } - } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingNotification.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingNotification.php similarity index 73% rename from docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingNotification.php rename to docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingNotification.php index 37381cb1ce..10b0421131 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/MetrolistListingNotification.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingNotification.php @@ -1,24 +1,18 @@ setField("TextBody", $text); + $email_object->setField("TextBody", $text); } /** * @inheritDoc */ - public static function templateHtmlText(&$emailFields):void { - - $cobdata = &$emailFields["email_object"]; + public static function templateHtmlText(array &$payload, CobEmail &$email_object):void { $vars = self::_getRequestParams(); @@ -98,43 +90,32 @@ public static function templateHtmlText(&$emailFields):void { {$weblink}\n

View Pending Development Units

\n -

This submission was made via the Metrolist Listing Form on Boston.gov.

\n\n +

This submission was made via the Metrolist Listing Form on Boston.gov.

\n\n
\n "; - $emailFields["HtmlBody"] = self::_makeHtml($html, $emailFields["subject"]); + $payload["HtmlBody"] = self::_makeHtml($html, $payload["subject"]); - $cobdata->setField("HtmlBody", $html); + $email_object->setField("HtmlBody", $html); } /** * @inheritDoc */ - public static function formatOutboundEmail(array &$emailFields): void { - - $cobdata = &$emailFields["email_object"]; + public static function parseEmailFields(array &$payload, CobEmail &$email_object): void { - $cobdata->setField("Tag", "metrolist notification"); + // Do the base email fields processing first. + parent::parseEmailFields($payload, $email_object); - $cobdata->setField("endpoint", $emailFields["endpoint"] ?: EmailController::POSTMARK_DEFAULT_ENDPOINT); + $email_object->setField("Tag", "metrolist notification"); - self::templatePlainText($emailFields); - if (!empty($emailFields["useHtml"])) { - self::templateHtmlText($emailFields); + self::templatePlainText($payload, $email_object); + if (!empty($payload["useHtml"])) { + self::templateHtmlText($payload, $email_object); } // Create a hash of the original poster's email - $cobdata->setField("To", $emailFields["to_address"]); - $cobdata->setField("From", $emailFields["from_address"]); - !empty($emailFields['cc']) && $cobdata->setField("Cc", $emailFields['cc']); - !empty($emailFields['bcc']) && $cobdata->setField("Bcc", $emailFields['bcc']); - $cobdata->setField("Subject", $emailFields["subject"]); - !empty($emailFields['headers']) && $cobdata->setField("Headers", $emailFields['headers']); - - // Remove redundant fields - $cobdata->delField("TemplateModel"); - $cobdata->delField("TemplateID"); } @@ -239,23 +220,6 @@ public static function _makeHtml(string $html, string $title): string { } - /** - * @inheritDoc - */ - public static function getHoneypotField(): string { - // TODO: Implement honeypot() method. - return ""; - } - - /** - * @inheritDoc - */ - public static function getEmailService(): EmailServiceInterface { - $config = \Drupal::service("config.factory")->get("bos_email.settings"); - $email_service = $config->get(self::getGroupID() . ".service"); - $email_service = "Drupal\\bos_email\\Services\\{$email_service}"; - return new $email_service; - } /** * @inheritDoc @@ -264,11 +228,4 @@ public static function getGroupID(): string { return "metrolist"; } - /** - * @inheritDoc - */ - public static function formatInboundEmail(array &$emailFields): void { - // TODO: Implement incoming() method. - } - } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Registry.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Registry.php new file mode 100644 index 0000000000..53fc66cade --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Registry.php @@ -0,0 +1,121 @@ + 'buildForm', + BosCoreFormEvent::CONFIG_FORM_SUBMIT => 'submitForm', +// BosCoreFormEvent::CONFIG_FORM_VALIDATE => 'validateForm' + ]; + } + + /** + * @inheritDoc + */ + public static function parseEmailFields(array &$payload, CobEmail &$email_object): void { + + // Do the base email fields processing first. + parent::parseEmailFields($payload, $email_object); + + $email_object->setField("TemplateID", $payload["template_id"]); + + isset($emailFields["name"]) && $email_object->setField("ReplyTo", "{$payload["name"]}<{$emailFields["from_address"]}>"); + + // Create a relevant tag. + if (str_contains($payload["subject"], "Birth")) { + $email_object->setField("Tag", "Birth Certificate"); + } + elseif (str_contains($payload["subject"], "Intention")) { + $email_object->setField("Tag", "Marriage Intention"); + } + elseif (str_contains($payload["subject"], "Death")) { + $email_object->setField("Tag", "Death Certificate"); + } + + } + + /** + * @inheritDoc + */ + public static function getHoneypotField(): string { + return ""; + } + + /** + * @inheritDoc + */ + public static function getGroupID(): string { + return "registry"; + } + + /** + * @inheritDoc + */ + public static function buildForm(BosCoreFormEvent $event): void { + + if ($event->getEventType() == "bos_email_config_settings") { + $form = $event->getForm(); + $form["bos_email"]["registry"] = [ + '#type' => 'fieldset', + '#title' => 'Registry Suite', + '#markup' => 'Emails from the Registry App - confirmations.', + '#collapsible' => FALSE, + '#weight' => 1, + + "service" => [ + "#type" => "select", + '#title' => t('Registry Email Service'), + '#description' => t('The Email Service which is currently being used.'), + "#options" => $form["service_options"], + '#default_value' => $event->getConfig('registry.service') + ], + "template" => [ + "#type" => "textfield", + '#title' => t('Default Registry Email Template'), + '#description' => t('The ID for the template being used -leave blank if no template is required.'), + '#default_value' => $event->getConfig('registry.template') + ], + "enabled" => [ + '#type' => 'checkbox', + '#title' => t('Registry email service enabled'), + '#default_value' => $event->getConfig('registry.enabled'), + ], + "q_enabled" => [ + '#type' => 'checkbox', + '#title' => t('Registry queue processing enabled'), + '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), + '#default_value' => $event->getConfig('registry.q_enabled'), + ], + ]; + + $event->setForm($form); + } + } + + /** + * @inheritDoc + */ + public static function submitForm(BosCoreFormEvent $event): void { + if ($event->getEventType() == "bos_email_config_settings") { + $input = $event->getFormState()->getUserInput()["bos_email"]; + $event->setConfig("registry.service", $input["registry"]["service"]); + $event->setConfig("registry.template", $input["registry"]["template"]); + $event->setConfig("registry.enabled", $input["registry"]["enabled"] ?? 0); + $event->setConfig("registry.q_enabled", $input["registry"]["q_enabled"] ?? 0); + + } + } +} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Sanitation.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Sanitation.php new file mode 100644 index 0000000000..056378340c --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Sanitation.php @@ -0,0 +1,150 @@ + 'buildForm', + BosCoreFormEvent::CONFIG_FORM_SUBMIT => 'submitForm', + ]; + } + + /** + * @inheritDoc + */ + public static function getGroupID(): string { + return "sanitation"; + } + + /** + * Decodes the payload from a json string into an associative array. + * + * @param string $payload + * + * @return void + */ + public static function fetchPayload(Request $request): array { + + if ($request->getContentTypeFormat() != "json") { + return []; + } + + $payload = $request->getContent(); + + if ($payload) { + try { + return json_decode($payload, TRUE); + } + catch (Exception $e) { + return []; + } + } + return []; + } + + /** + * @inheritDoc + */ + public static function parseEmailFields(array &$payload, CobEmail &$email_object): void { + + // Do the base email fields processing first. + parent::parseEmailFields($payload, $email_object); + + // Set up the Postmark template. + $template_id = \Drupal::config("bos_email.settings")->get("sanitation.template"); + $email_object->setField("TemplateID", $template_id); + $email_object->setField("Tag", $payload["type"]); + + // Template expects "subject" not the default "Subject" + $email_object->setField("TemplateModel", ["subject" => $email_object->getField("Subject")]); + + // is this to be scheduled? + if (!empty($payload["senddatetime"])) { + $email_object->setSendDate($payload["senddatetime"]); + } + else { + // Remove the field from the object. + $email_object->delField("senddatetime"); + } + + } + + /** + * @inheritDoc + */ + public static function buildForm(BosCoreFormEvent $event): void { + + if ($event->getEventType() == "bos_email_config_settings") { + $form = $event->getForm(); + $form["bos_email"]["sanitation"] = [ + '#type' => 'fieldset', + '#title' => 'Sanitation Email Services', + '#markup' => 'Emails sent from Sanitation WebApp.', + '#collapsible' => FALSE, + '#weight' => 3, + + "service" => [ + "#type" => "select", + '#title' => t('Sanitation Email Service'), + '#description' => t('The Email Service which is currently being used.'), + "#options" => $form["service_options"], + '#default_value' => $event->getConfig('sanitation.service') + ], + "template" => [ + "#type" => "textfield", + '#title' => t('Default Sanitation Email Template'), + '#description' => t('The ID for the template being used -leave blank if no template is required.'), + '#default_value' => $event->getConfig('sanitation.template') + ], + "enabled" => [ + '#type' => 'checkbox', + '#title' => t('Sanitation email service enabled'), + '#default_value' => $event->getConfig('sanitation.enabled'), + ], + "q_enabled" => [ + '#type' => 'checkbox', + '#title' => t('Sanitation queue processing enabled'), + '#description' => t('When selected, emails which initially fail to send are queued will be processed on each cron run.'), + '#default_value' => $event->getConfig('sanitation.q_enabled'), + ], + "sched_enabled" => [ + '#type' => 'checkbox', + '#title' => t('Sanitation scheduled email processing enabled'), + '#description' => t('When selected, scheduled emails are queued will be processed on each cron run.'), + '#default_value' => $event->getConfig('sanitation.sched_enabled'), + ], + ]; + + $event->setForm($form); + } + } + + /** + * @inheritDoc + */ + public static function submitForm(BosCoreFormEvent $event): void { + if ($event->getEventType() == "bos_email_config_settings") { + $input = $event->getFormState()->getUserInput()["bos_email"]; + $event->setConfig("sanitation.service", $input["sanitation"]["service"]); + $event->setConfig("sanitation.template", $input["sanitation"]["template"]); + $event->setConfig("sanitation.enabled", $input["sanitation"]["enabled"] ?? 0); + $event->setConfig("sanitation.sched_enabled", $input["sanitation"]["sched_enabled"] ?? 0); + $event->setConfig("sanitation.q_enabled", $input["sanitation"]["q_enabled"] ?? 0); + } + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php index 15aa959db7..9f5eaf578e 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php @@ -5,14 +5,30 @@ use Boston; use Drupal; use Drupal\bos_email\EmailServiceInterface; +use Drupal\Core\Render\Markup; +use Drupal\Core\Site\Settings; +use Drupal\bos_email\CobEmail; /** * Postmark class for API. */ class DrupalService implements EmailServiceInterface { - const MESSAGE_SENT = 'Message sent.'; - const MESSAGE_QUEUED = 'Message queued.'; + const DEFAULT_ENDPOINT = 'internal'; + const TEMPLATE_ENDPOINT = 'internal'; + + /** + * @var array Retains the request + */ + protected array $request; + + /** + * An associative array created from the most recent CuRL transaction and + * which can be extended by any service extending this class. + * + * @var array + */ + protected array $response; public null|string $error; @@ -23,6 +39,15 @@ public function id():string { return "bos_email.DrupalService"; } + /** + * @inheritDoc + */ + public function updateEmailObject(CobEmail &$email_object): void { + $email_object->setField("endpoint", self::DEFAULT_ENDPOINT); + $email_object->delField("TemplateID"); + $email_object->delField("TemplateModel"); + } + /** * Send the email via Postmark. * @@ -32,6 +57,18 @@ public function id():string { */ public function sendEmail(array $item):bool { + // Check if we are sending out emails. + $config = Drupal::configFactory()->get("bos_email.settings"); + if (!$config->get("enabled")) { + $this->error = "Emailing temporarily suspended for all emails"; + Drupal::logger("bos_email:DrupalService")->error($this->error); + return FALSE; + } + elseif ($item["server"] && !$config->get(strtolower($item["server"]))["enabled"]) { + $this->error = "Emailing temporarily suspended for {$item["server"]} emails."; + Drupal::logger("bos_email:DrupalService")->error($this->error); + return FALSE; + } /** * @var \Drupal\Core\Mail\MailManager $mailManager @@ -40,56 +77,133 @@ public function sendEmail(array $item):bool { // Send the email. $item["_error_message"] = ""; - $key = "{$this->service}.{$item["Tag"]}"; $mailManager = Drupal::service('plugin.manager.mail'); + $sent = $mailManager->mail("bos_email", $item["server"] , $item["To"], "en", $item, $item["ReplyTo"], TRUE); - $sent = $mailManager->mail("bos_email", $key , $item["To"], "en", $item, NULL, TRUE); + $this->response = [ + "sent" => $sent ? "True" : "False", + ]; if (!$sent || !$sent["result"]) { if (!empty($params["_error_message"])) { + $this->response["error"] = $params["_error_message"]; throw new \Exception($params["_error_message"]); } else { + $this->response["error"] = "Error sending email"; throw new \Exception("Error sending email."); } } - $response_message = self::MESSAGE_SENT; + return TRUE; } catch (\Exception $e) { - try { - $this->addQueueItem($item); - } - catch (\Exception $ee) { - Drupal::logger("bos_email:DrupalService")->info("Failed to queued mail item in {$item->getField("server")}"); - return [ - 'status' => 'error', - 'response' => "Error sending message {$e->getMessage()}, then error queueing item {$ee->getMessage()}.", - ]; - } - + $this->error = $e->getMessage(); + $this->response["error"] = $this->error; if (Boston::is_local()) { - Drupal::logger("bos_email:DrupalService")->info("Queued {$item->getField("server")}"); + Drupal::logger("bos_email:DrupalService") + ->info("Queued {$item["server"]}"); + return FALSE; } - $response_message = self::MESSAGE_QUEUED; } - - return [ - 'status' => 'success', - 'response' => $response_message, - ]; - } /** * @inheritDoc */ public function getVars(): array { - // TODO: Implement getVars() method. + + $postmark_env = []; + if (getenv('POSTMARK_SETTINGS')) { + $get_vars = explode(",", getenv('POSTMARK_SETTINGS')); + foreach ($get_vars as $item) { + $json = explode(":", $item); + if (!empty($json[0]) && !empty($json[1])) { + $postmark_env[$json[0]] = $json[1]; + } + } + } + else { + $postmark_env = Settings::get('postmark_settings') ?? []; + } + + return $postmark_env; + } + + /** + * Format the body part of the email using twig templates. + * The idea here is to create Markup in the $message["body"] field. + * + * Called from bos_email.module bos_email_mail(). + * + * @param array $params the Drupal mail params object + * @param array $message the Drupal mail message object + * + * @return void + */ + public static function renderEmail(array &$params, array &$message):void { + + // Map in the default values + $message["from"] = $params["From"]; + $message["subject"] = $params["Subject"]; + $message["reply-to"] = $params["ReplyTo"]; + !empty($params["Cc"]) && $message['headers']["CC"] = $params["Cc"]; + !empty($params["Bcc"]) && $message['headers']["BCC"] = $params["Bcc"]; + $message['headers']['Content-Type'] = 'text/html; charset=UTF-8; format=flowed; delsp=yes'; + + if (!empty($params["useHTML"]) && $params["useHTML"] == 1) { + // The $params["message"] field is already in HTML, so just use it. + // No twig templating required for the body. + if (is_string($params["message"])) { + $params["message"] = Markup::create($params["message"]); + } + $message["body"] = $params["message"]; + return; + } + + // Get the template name. + $path = Drupal::service('extension.list.module') + ->get('bos_email') + ->getPath(); + $twig_service = Drupal::service('twig'); + + // Try to find the generic template + $template_name = "{$path}/templates/{$params["server"]}.body.html.twig"; + if (file_exists($template_name)) { + $rendered_template = $twig_service->render($template_name, $params); + $params['message'] = Markup::create($rendered_template); + $message["body"] = $params["message"]; + return; + } + + // Create a variant field which the template can use to determine body + // copy/format. + if (!empty($params["Tag"])) { + $params['variant'] = "{$params["server"]}.body.{$params["Tag"]}"; + // Try to find the processor-specific template + $template_name = "{$path}/templates/{$params["variant"]}.html.twig"; + if (file_exists($template_name)) { + $rendered_template = $twig_service->render($template_name, $params); + $params['message'] = Markup::create($rendered_template); + $message["body"] = $params["message"]; + return; + } + } + + // Use a generic body template. + $template_name = "{$path}/templates/default.body.html.twig"; + $rendered_template = $twig_service->render($template_name, $params); + $params['message'] = Markup::create($rendered_template); + $message["body"] = $params["message"]; + + } + + public function response(): array { + return $this->response ?? []; } } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php index 6da7f84405..064f416b4d 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php @@ -3,6 +3,7 @@ namespace Drupal\bos_email\Services; use Drupal\bos_core\Controllers\Curl\BosCurlControllerBase; +use Drupal\bos_email\CobEmail; use Drupal\bos_email\EmailServiceInterface; use Drupal\Core\Site\Settings; use Exception; @@ -12,10 +13,13 @@ */ class PostmarkService extends BosCurlControllerBase implements EmailServiceInterface { + const DEFAULT_ENDPOINT = 'https://api.postmarkapp.com/email'; + const TEMPLATE_ENDPOINT = "https://api.postmarkapp.com/email/withTemplate"; + // Make this protected var from BosCurlControllerBase public public null|string $error; - public array $response; +// public array $response; /** * @inheritDoc @@ -46,6 +50,43 @@ public function getVars(): array { return $postmark_env; } + /** + * @inheritDoc + */ + public function updateEmailObject(CobEmail &$email_object): void { + if (empty($email_object->getField("TemplateID"))) { + $email_object->setField("endpoint", $this::DEFAULT_ENDPOINT); + $email_object->delField("TemplateID"); + $email_object->delField("TemplateModel"); + } + else { + if (!is_numeric($email_object->getField("TemplateID"))) { + // For PostMark Templates, the tmeplate can be referred to using a + // numeric ID or an alias. + // Looks like an alias is being used, so update the $email_object to + // send the alias not the ID. + $email_object->addField("TemplateAlias", $email_object::FIELD_STRING, $email_object->getField("TemplateID")); + $email_object->delField("TemplateID"); + } + $email_object->setField("endpoint", $this::TEMPLATE_ENDPOINT); + + // If the EmailProcessor (in ::parseEmailFields()) has set the + // TemplateModel (a set of arguments to pass to the template) then merge + // these defaults in, but retain the existing. + $model = [ + "Subject" => $email_object->getField("Subject"), + "TextBody" => ($email_object->getField("TextBody") ?? ($email_object->getField("HtmlBody") ?? ($email_object->getField("message") ?? ""))), + "ReplyTo" => $email_object->getField("ReplyTo"), + ]; + $model = array_merge($model, $email_object->getField("TemplateModel")); + $email_object->setField("TemplateModel", $model); + // Cleanup + $email_object->delField("ReplyTo"); + $email_object->delField("Subject"); + $email_object->delField("TextBody"); + } + } + /** * Send email to Postmark. */ @@ -54,7 +95,7 @@ public function sendEmail(array $item):bool { // Check if we are sending out emails. $config = \Drupal::configFactory()->get("bos_email.settings"); if (!$config->get("enabled")) { - $this->error = "Emailing temporarily suspended for all PostMark emails"; + $this->error = "Emailing temporarily suspended for all emails"; \Drupal::logger("bos_email:PostmarkService")->error($this->error); return FALSE; } @@ -91,12 +132,13 @@ public function sendEmail(array $item):bool { throw new \Exception("Posting Error {$this->response["http_code"]}
HEADERS: {$headers}
PAYLOAD: {$item}
RESPONSE:{$response}"); } - if (strtolower($response["ErrorCode"]) != "0") { + if (!$response && !empty($this->response["response_raw"])) { + $response = json_decode($this->response["response_raw"], TRUE); + $this->error = "Error code ({$response["ErrorCode"]}) returned from Postmark - {$response["Message"]}"; $headers = json_encode($headers); $item = json_encode($item); $response = json_encode($this->response); - $this->error = "Error code returned from Postmark - {$response["Message"]}"; - throw new \Exception("Return Error Code: {$response['ErrorCode']}
HEADERS: {$headers}
PAYLOAD: {$item}
RESPONSE:{$response}"); + throw new \Exception("Return Error Code: {$this->response["http_code"]}
HEADERS: {$headers}
PAYLOAD: {$item}
RESPONSE:{$response}"); } return TRUE; diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Contactform.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Contactform.php deleted file mode 100644 index 6f77ddefc2..0000000000 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Contactform.php +++ /dev/null @@ -1,197 +0,0 @@ -setField("Tag", "Contact Form"); - - if (isset($emailFields["endpoint"])) { - $cobdata->addField("endpoint", "string", $emailFields["endpoint"]); - } - else { - $cobdata->addField("endpoint", "string", EmailController::POSTMARK_DEFAULT_ENDPOINT); - } - - self::templatePlainText($emailFields); - if (!empty($emailFields["useHtml"])) { - self::templateHtmlText($emailFields); - } - - // Create a hash of the original poster's email - $hashemail = $cobdata::encodeFakeEmail($emailFields["from_address"], self::OUTBOUND_DOMAIN ); - $cobdata->setField("Metadata", [ - "opmail" => $cobdata::hashText($emailFields["from_address"], $cobdata::ENCODE) - ]); - - $cobdata->setField("To", $emailFields["to_address"]); - $cobdata->setField("From", "Boston.gov Contact Form <{$hashemail}>"); - $cobdata->setField("ReplyTo", $emailFields["from_address"]); - isset($emailFields["name"]) && $cobdata->setField("ReplyTo", "{$emailFields["name"]}<{$emailFields["from_address"]}>"); - !empty($emailFields['cc']) && $cobdata->setField("Cc", $emailFields['cc']); - !empty($emailFields['bcc']) && $cobdata->setField("Bcc", $emailFields['bcc']); - $cobdata->setField("Subject", $emailFields["subject"]); - !empty($emailFields['headers']) && $cobdata->setField("Headers", $emailFields['headers']); - !empty($emailFields['tag']) && $cobdata->setField("Tag", $emailFields['tag']); - - if (empty($emailFields["TemplateID"]) && empty($emailFields["template_id"])) { - // Remove redundant fields - $cobdata->delField("TemplateModel"); - $cobdata->delField("TemplateID"); - } - else { - // An email template is to be used. - $cobdata->setField("endpoint", EmailController::POSTMARK_TEMPLATE_ENDPOINT); - $cobdata->delField("TextBody"); - $cobdata->delField("Subject"); - $cobdata->delField("HtmlBody"); - } - - } - - /** - * @inheritDoc - */ - public static function templatePlainText(&$emailFields): void { - - $cobdata = &$emailFields["email_object"]; - $msg = strip_tags($emailFields["message"]); - - if (empty($emailFields["TemplateID"]) && empty($emailFields["template_id"])) { - $text = "-- REPLY ABOVE THIS LINE -- \n\n"; - $text .= "{$msg}\n\n"; - $text .= "{$emailFields["phone"]}\n\n"; - $text .= "-------------------------------- \n"; - $text .= "This message was sent using the contact form on Boston.gov."; - $text .= " It was sent by {$emailFields["name"]} from {$emailFields["from_address"]} and {$emailFields["phone"]}."; - $text .= " It was sent from {$emailFields["url"]}.\n\n"; - $text .= "-------------------------------- \n"; - $cobdata->setField("TextBody", $text); - } - else { - // we are using a template - $cobdata->delField("TextBody"); - $cobdata->setField("TemplateID", $emailFields['TemplateID']); - $cobdata->setField("TemplateModel", [ - "subject" => $emailFields["subject"], - "TextBody" => $msg, - "ReplyTo" => $emailFields["from_address"], - ]); - $emailFields["useHtml"] = 0; - } - - } - - /** - * @inheritDoc - */ - public static function templateHtmlText(&$emailFields): void { - - if (empty($emailFields["TemplateID"]) && empty($emailFields["template_id"])) { - - $cobdata = &$emailFields["email_object"]; - - $msg = Html::escape(Xss::filter($emailFields["message"])); - $msg = str_replace("\n", "
", $msg); - - $html = "
----- REPLY ABOVE THIS LINE -----

"; - $html .= "
{$msg}
"; - $html .= "
"; - $html .= "{$emailFields["phone"]}"; - $html .= "
"; - $html .= ""; - $html .= ""; - $html .= "
This message was sent using the contact form on Boston.gov.
"; - $html .= " It was sent by {$emailFields["name"]} from {$emailFields["from_address"]} and {$emailFields["phone"]}.
"; - $html .= " It was sent from {$emailFields["url"]}.
"; - $html .= "
"; - - $cobdata->setField("HtmlBody", $html); - - } - - } - - /** - * @inheritDoc - */ - public static function formatInboundEmail(array &$emailFields): void { - -// if ($emailFields["endpoint"]->getField("server") == "contactform" -// && str_contains($emailFields["OriginalRecipient"], "@web-inbound.boston.gov")) { -// $server = EmailController::AUTORESPONDER_SERVERNAME; -// } - - // Find the original recipient - - // Create the email. - /** - * @var $cobdata CobEmail - */ - $cobdata = &$emailFields["email_object"]; - $original_recipient = $cobdata::decodeFakeEmail($emailFields["OriginalRecipient"]); - $cobdata->setField("To", $original_recipient); - $cobdata->setField("From", "contactform@boston.gov"); - $cobdata->setField("Subject", $emailFields["Subject"]); - $cobdata->setField("HtmlBody", $emailFields["HtmlBody"]); - $cobdata->setField("TextBody", $emailFields["TextBody"]); - $cobdata->setField("endpoint", EmailController::POSTMARK_DEFAULT_ENDPOINT); - // Select Headers - $cobdata->processHeaders($emailFields["Headers"]); - - // Remove redundant fields - $cobdata->delField("TemplateModel"); - $cobdata->delField("TemplateID"); - - } - - /** - * @inheritDoc - */ - public static function getHoneypotField(): string { - return "contact"; - } - - /** - * @inheritDoc - */ - public static function getEmailService(): EmailServiceInterface { - $config = \Drupal::service("config.factory")->get("bos_email.settings"); - $email_service = $config->get(self::getGroupID() . ".service"); - $email_service = "Drupal\\bos_email\\Services\\{$email_service}"; - return new $email_service; - } - - /** - * @inheritDoc - */ - public static function getGroupID(): string { - return "contactform"; - } - -} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Registry.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Registry.php deleted file mode 100644 index 04b7875909..0000000000 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Registry.php +++ /dev/null @@ -1,100 +0,0 @@ -setField("endpoint", EmailController::POSTMARK_TEMPLATE_ENDPOINT); - - // Set up the Postmark template. - $cobdata->setField("TemplateID", $emailFields["template_id"]); - $cobdata->setField("TemplateModel", [ - "subject" => $emailFields["subject"], - "TextBody" => $emailFields["message"], - "ReplyTo" => $emailFields["from_address"] - ]); - $cobdata->delField("HtmlBody"); - $cobdata->delField("TextBody"); - $cobdata->delField("Subject"); - - // Set general email fields. - $cobdata->setField("To", $emailFields["to_address"]); - $cobdata->setField("From", $emailFields["from_address"]); - isset($emailFields["name"]) && $cobdata->setField("ReplyTo", "{$emailFields["name"]}<{$emailFields["from_address"]}>"); - !empty($emailFields['cc']) && $cobdata->setField("Cc", $emailFields['cc']); - !empty($emailFields['bcc']) && $cobdata->setField("Bcc", $emailFields['bcc']); - - // Create a relevant tag. - if (str_contains($emailFields["subject"], "Birth")) { - $cobdata->setField("Tag", "Birth Certificate"); - } - elseif (str_contains($emailFields["subject"], "Intention")) { - $cobdata->setField("Tag", "Marriage Intention"); - } - elseif (str_contains($emailFields["subject"], "Death")) { - $cobdata->setField("Tag", "Death Certificate"); - } - - } - - /** - * @inheritDoc - */ - public static function templatePlainText(&$emailFields): void { - } - - /** - * @inheritDoc - */ - public static function templateHtmlText(&$emailFields): void { - } - - /** - * @inheritDoc - */ - public static function getHoneypotField(): string { - return ""; - } - - /** - * @inheritDoc - */ - public static function getEmailService(): EmailServiceInterface { - $config = \Drupal::service("config.factory")->get("bos_email.settings"); - $email_service = $config->get(self::getGroupID() . ".service"); - $email_service = "Drupal\\bos_email\\Services\\{$email_service}"; - return new $email_service; - } - - - /** - * @inheritDoc - */ - public static function getGroupID(): string { - return "registry"; - } - - /** - * @inheritDoc - */ - public static function formatInboundEmail(array &$emailFields): void { - // TODO: Implement incoming() method. - } - -} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php deleted file mode 100644 index 1c597edb74..0000000000 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/Sanitation.php +++ /dev/null @@ -1,105 +0,0 @@ -setField("endpoint", EmailController::POSTMARK_TEMPLATE_ENDPOINT); - - // Set up the Postmark template. - $template_id = \Drupal::config("bos_email.settings")->get("sanitation.template"); - $cobdata->setField("TemplateID", $template_id); - $cobdata->setField("TemplateModel", [ - "subject" => $emailFields["subject"], - "TextBody" => $emailFields["message"], - "ReplyTo" => $emailFields["from_address"] - ]); - $cobdata->delField("HtmlBody"); - $cobdata->delField("TextBody"); - $cobdata->delField("Subject"); - - // Set general email fields. - $cobdata->setField("To", $emailFields["to_address"]); - $cobdata->setField("From", $emailFields["from_address"]); - $cobdata->setField("ReplyTo", $emailFields["from_address"]); - - $cobdata->setField("Tag", $emailFields["type"]); - - // is this to be scheduled? - if (!empty($emailFields["senddatetime"])) { - try { - $senddatetime = strtotime($emailFields["senddatetime"]); - $cobdata->setField("senddatetime", $senddatetime); - } - catch (Exception $e) { - $cobdata->delField("senddatetime"); - } - } - else { - $cobdata->delField("senddatetime"); - } - - } - - /** - * @inheritDoc - */ - public static function templatePlainText(&$emailFields): void { - // Only use templates ATM. - } - - /** - * @inheritDoc - */ - public static function templateHtmlText(&$emailFields): void { - // Only use templates ATM. - } - - /** - * @inheritDoc - */ - public static function getHoneypotField(): string { - return ""; - } - - /** - * @inheritDoc - */ - public static function getEmailService(): EmailServiceInterface { - $config = \Drupal::service("config.factory")->get("bos_email.settings"); - $email_service = $config->get(self::getGroupID() . ".service"); - $email_service = "Drupal\\bos_email\\Services\\{$email_service}"; - return new $email_service; - } - - /** - * @inheritDoc - */ - public static function getGroupID(): string { - return "sanitation"; - } - - /** - * @inheritDoc - */ - public static function formatInboundEmail(array &$emailFields): void { - // Not Used - } - -} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/TestDrupalmail.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/TestDrupalmail.php deleted file mode 100644 index 99a8645ea5..0000000000 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Templates/TestDrupalmail.php +++ /dev/null @@ -1,68 +0,0 @@ -setField("To", $emailFields["To"]); - $data->setField("From", "Drupal {$data->getField("endpoint")} Test "); - $data->setField("Subject", $emailFields["Subject"]); - $data->setField("TextBody", $emailFields["TextBody"]); - - if ($emailFields["htmlBody"]) { - $data->setField("HtmlBody", $emailFields["HtmlBody"]); - } - else{ - $data->delField("HtmlBody"); - } - - $data->delField("TemplateID"); - $data->delField("TemplateModel"); - } - - /** - * @inheritDoc - */ - public static function templatePlainText(&$emailFields): void { } - - /** - * @inheritDoc - */ - public static function templateHtmlText(&$emailFields): void { } - - /** - * @inheritDoc - */ - public static function formatInboundEmail(array &$emailFields): void { } - - /** - * @inheritDoc - */ - public static function getHoneypotField(): string { - return ""; - } - - /** - * @inheritDoc - */ - public static function getGroupID(): string { - return "drupal_mail"; - } - -} diff --git a/docroot/modules/custom/bos_components/modules/bos_email/templates/default.body.html.twig b/docroot/modules/custom/bos_components/modules/bos_email/templates/default.body.html.twig new file mode 100644 index 0000000000..11ae84343b --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/templates/default.body.html.twig @@ -0,0 +1,28 @@ +{# +This is a standardized template copied from code used by Postmark. +The template receives the following useful fields: + To: The email recipient + ReplyTo: The reply-to recipient + From: The email sender + server: The email-caller service + Tag: The email-caller sub-category + Subject: The email subject + TextBody: The raw text for the body + server: the Email Processor used + service: the Email Service used (as a class) + _error_message: Any error message. + variant: The variant we are currently processing. +#} + + + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_email/templates/includes/standard_footer.html.twig b/docroot/modules/custom/bos_components/modules/bos_email/templates/includes/standard_footer.html.twig new file mode 100644 index 0000000000..474f39e379 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/templates/includes/standard_footer.html.twig @@ -0,0 +1,11 @@ + + + + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_email/templates/includes/standard_head.html.twig b/docroot/modules/custom/bos_components/modules/bos_email/templates/includes/standard_head.html.twig new file mode 100644 index 0000000000..9f50d0fa8f --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/templates/includes/standard_head.html.twig @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_email/templates/includes/standard_masthead.html.twig b/docroot/modules/custom/bos_components/modules/bos_email/templates/includes/standard_masthead.html.twig new file mode 100644 index 0000000000..be03f8c67c --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/templates/includes/standard_masthead.html.twig @@ -0,0 +1,8 @@ + + + + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_email/templates/mimemail-message--bos-email.html.twig b/docroot/modules/custom/bos_components/modules/bos_email/templates/mimemail-message--bos-email.html.twig new file mode 100644 index 0000000000..ff6b026653 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/templates/mimemail-message--bos-email.html.twig @@ -0,0 +1,72 @@ +{# +/** + * @file + * Default template to format a HTML email using the Mime Mail module. + * + * Copy this file in your default theme folder to create a custom themed email. + * If you modify this template you MUST be sure to keep the html, body, and + * header tags. This template should produce a fully-formed HTML document. + * Failure to include these will result in a malformed email and possibly + * errors shown to the user when sending email. + * + * To override this template for all emails sent by a given module, + * rename this template to mimemail-messages--[module].html.twig. + * + * To override this template for a specific email sent by a given module, + * rename this template to mimemail-messages--[module]--[key].html.twig. + * + * Available variables: + * - attributes: HTML attributes for the body element of the message. + * - key: The message identifier. + * - module: The machine name of the sending module. + * - css: Internal style sheets. + * - recipient: The recipient of the message. + * - subject: The message subject. + * - body: The message body. + * + * @see template_preprocess_mimemail_message() + * + * @ingroup themeable + */ +#} + +{% set classes = module ? (key ? module ~ '-' ~ key) %} + + + + + + + {% include '@bos_email/includes/standard_head.html.twig' %} + {% if css %} + + {% endif %} + + + +
+
+ + + + + + + + +
+
+ + + diff --git a/docroot/modules/custom/bos_components/modules/bos_email/templates/sanitation.body.html.twig b/docroot/modules/custom/bos_components/modules/bos_email/templates/sanitation.body.html.twig new file mode 100644 index 0000000000..388f16cbab --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_email/templates/sanitation.body.html.twig @@ -0,0 +1,29 @@ +{# +This is a standardized template copied from code used by Postmark. +The template receives the following useful fields: + To: The email recipient + ReplyTo: The reply-to recipient + From: The email sender + server: The email-caller service + Tag: The email-caller sub-category + Subject: The email subject + TextBody: The raw text for the body + server: the Email Processor used + service: the Email Service used (as a class) + _error_message: Any error message. + variant: The variant we are currently processing. +#} + + + + + + diff --git a/docroot/modules/custom/bos_core/src/Event/BosCoreFormEvent.php b/docroot/modules/custom/bos_core/src/Event/BosCoreFormEvent.php new file mode 100644 index 0000000000..16864e0906 --- /dev/null +++ b/docroot/modules/custom/bos_core/src/Event/BosCoreFormEvent.php @@ -0,0 +1,94 @@ +form = $form; + $this->form_state = $form_state; + $this->eventType = $event_type; + $this->config = $config; + } + + /** + * Method to get the form array from the event. + */ + public function getForm(): array { + return $this->form; + } + + public function setForm(array $form): void { + $this->form = $form; + } + + /** + * Method to get the form_state from the event. + */ + public function getFormState(): FormStateInterface { + return $this->form_state; + } + /** + * Method to get the event type. + */ + public function getEventType() { + return $this->eventType; + } + + public function getConfig(?string $key = NULL): string { + if (empty($key)) { + return $this->config->get(); + } + return $this->config->get($key) ?? FALSE; + } + + public function setConfig(string $key, string $value): void { + $this->config->set($key, $value)->save(); + } + +} From afea65177da119c1a239fc9cca38e42812c1e8ef Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 24 Apr 2024 18:44:34 -0400 Subject: [PATCH 06/48] Update http-client.env.json --- .../bos_components/modules/bos_email/http-client.env.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_email/http-client.env.json b/docroot/modules/custom/bos_components/modules/bos_email/http-client.env.json index 7c1239a994..545086bde2 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/http-client.env.json +++ b/docroot/modules/custom/bos_components/modules/bos_email/http-client.env.json @@ -1,7 +1,7 @@ { "LOCAL for testing": { "url": "https://boston.lndo.site", - "bos_email_bearer_token": "c43517a240ee61898c00600eaa775aa0d0e639322c3f275b780f66062f69", + "bos_email_bearer_token": "", "contact_form_session_token": "", "registry_template_id": "31135208", "email_test_recipient": "david.upton@boston.gov", @@ -9,7 +9,7 @@ }, "CI": { "url": "https://d8-ci.boston.gov", - "bos_email_bearer_token": "c43517a240ee61898c00600eaa775aa0d0e639322c3f275b780f66062f69", + "bos_email_bearer_token": "", "contact_form_session_token": "", "registry_template_id": "31135208", "email_test_recipient": "david.upton@boston.gov", From baf6e239fc6277352dce9b152e0ec6f8502e7d9f Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 24 Apr 2024 18:56:36 -0400 Subject: [PATCH 07/48] DIG-4317 Allows HTML formatting in Postmark templates --- .../custom/bos_components/modules/bos_email/bos_email.http | 2 +- .../bos_components/modules/bos_email/src/CobEmail.php | 2 ++ .../modules/bos_email/src/EmailProcessorInterface.php | 2 +- .../src/Plugin/EmailProcessor/EmailProcessorBase.php | 3 ++- .../bos_email/src/Plugin/EmailProcessor/Registry.php | 3 +++ .../bos_email/src/Plugin/EmailProcessor/Sanitation.php | 6 ++++-- .../modules/bos_email/src/Services/PostmarkService.php | 4 ++-- 7 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http index cd43f10093..5c7f53c1a6 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http @@ -70,7 +70,7 @@ Force-Service: {{ force_email_service }} "to_address": "{{email_test_recipient}}", "from_address": "Sanitation ", "subject": "Sanitation Confirmation", - "message": "We are pleased to confirm your pickup", + "message": "

We are pleased to confirm your pickup

", "type": "confirmation" } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php b/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php index 7a51a09df2..7dba4f999d 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/CobEmail.php @@ -23,6 +23,7 @@ class CobEmail { "TemplateModel" => [ "subject" => "", "TextBody" => "", + "HtmlBody" => "", "ReplyTo" => "", ], "endpoint" => "", @@ -48,6 +49,7 @@ class CobEmail { "TemplateModel" => [ "subject" => "string", "TextBody" => "string", + "HtmlBody" => "html", "ReplyTo" => "email", ], "service" => "string", diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailProcessorInterface.php b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailProcessorInterface.php index 0dcc94cbe5..64a310c988 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailProcessorInterface.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailProcessorInterface.php @@ -8,7 +8,7 @@ interface EmailProcessorInterface { /** - * Custom mpaaing of the data in $payload into the structured array $emailFields. + * Custom mapping of the data in $payload into the structured array $emailFields. * We can create new fields in the $emailFields using ->addField but this * should be an unusal circumstance. More commnly, when using a Template, we * can set additional template arguments/parameters from the payload in this diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/EmailProcessorBase.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/EmailProcessorBase.php index baeddebe85..d2453fd158 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/EmailProcessorBase.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/EmailProcessorBase.php @@ -85,7 +85,8 @@ public static function parseEmailFields(array &$payload, CobEmail &$email_object $email_object->setField("Tag", ($payload['tag'] ?? "")); $email_object->setField("To", ($payload["to_address"] ?? ($payload["to"] ?? ($payload["recipient"] ?? "")))); $email_object->setField("From", ($payload["modified_from_address"] ?? ($payload["from_address"] ?? ""))); - $email_object->setField("TextBody", ($payload["message"] ?? ($payload["body"] ?? ""))); + $email_object->setField("TextBody", ($payload["TextBody"] ?: ($payload["message"] ?: ($payload["body"] ?: "")))); + $email_object->setField("HtmlBody", ($payload["HtmlBody"] ?: ($payload["message"] ?: ($payload["body"] ?: "")))); $email_object->setField("ReplyTo", ($payload["reply_to"] ?? ($payload["from_address"] ?? ""))); !empty($payload['subject']) && $email_object->setField("Subject", $payload["subject"]); !empty($payload['cc']) && $email_object->setField("Cc", $payload['cc']); diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Registry.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Registry.php index 53fc66cade..46878f41fe 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Registry.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Registry.php @@ -33,6 +33,9 @@ public static function parseEmailFields(array &$payload, CobEmail &$email_object $email_object->setField("TemplateID", $payload["template_id"]); isset($emailFields["name"]) && $email_object->setField("ReplyTo", "{$payload["name"]}<{$emailFields["from_address"]}>"); + $email_object->setField("TemplateModel", [ + "TextBody" => ($email_object->getField("TextBody") ?: ($email_object->getField("HtmlBody") ?: ($email_object->getField("message") ?: ""))), + ]); // Create a relevant tag. if (str_contains($payload["subject"], "Birth")) { diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Sanitation.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Sanitation.php index 056378340c..c4e81b44db 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Sanitation.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Sanitation.php @@ -69,8 +69,10 @@ public static function parseEmailFields(array &$payload, CobEmail &$email_object $email_object->setField("TemplateID", $template_id); $email_object->setField("Tag", $payload["type"]); - // Template expects "subject" not the default "Subject" - $email_object->setField("TemplateModel", ["subject" => $email_object->getField("Subject")]); + $email_object->setField("TemplateModel", [ + "TextBody" => ($email_object->getField("TextBody") ?: ($email_object->getField("HtmlBody") ?: ($email_object->getField("message") ?: ""))), + "HtmlBody" => ($email_object->getField("HtmlBody") ?: ($email_object->getField("TextBody") ?: ($email_object->getField("message") ?: ""))), + ]); // is this to be scheduled? if (!empty($payload["senddatetime"])) { diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php index 064f416b4d..7bef0a848e 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php @@ -74,8 +74,7 @@ public function updateEmailObject(CobEmail &$email_object): void { // TemplateModel (a set of arguments to pass to the template) then merge // these defaults in, but retain the existing. $model = [ - "Subject" => $email_object->getField("Subject"), - "TextBody" => ($email_object->getField("TextBody") ?? ($email_object->getField("HtmlBody") ?? ($email_object->getField("message") ?? ""))), + "subject" => $email_object->getField("Subject"), "ReplyTo" => $email_object->getField("ReplyTo"), ]; $model = array_merge($model, $email_object->getField("TemplateModel")); @@ -84,6 +83,7 @@ public function updateEmailObject(CobEmail &$email_object): void { $email_object->delField("ReplyTo"); $email_object->delField("Subject"); $email_object->delField("TextBody"); + $email_object->delField("HtmlBody"); } } From d4c990d334b9b4fc7d149e7405b3c46d8b0ea4b5 Mon Sep 17 00:00:00 2001 From: David Upton Date: Thu, 25 Apr 2024 09:07:53 -0400 Subject: [PATCH 08/48] DIG-4317 Extra testing --- .../modules/bos_email/bos_email.http | 33 +------------------ 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http index 5c7f53c1a6..6d60ed3f6a 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http @@ -64,7 +64,7 @@ POST {{url}}/rest/email/sanitation Cookie: XDEBUG_SESSION=PHPSTORM Content-Type: application/json Authorization: Token {{bos_email_bearer_token}} -Force-Service: {{ force_email_service }} +#Force-Service: {{ force_email_service }} { "to_address": "{{email_test_recipient}}", @@ -1011,34 +1011,3 @@ Force-Service: {{ force_email_service }} client.assert(response.body.response.indexOf("email is not valid") != false, "Response does not report a bad email address"); }); %} - -### -# group: bos_email / fail-test -# @name Bad scheduled date -POST {{url}}/rest/email/sanitation -Cookie: XDEBUG_SESSION=PHPSTORM -Content-Type: application/json -Authorization: Token {{bos_email_bearer_token}} -Force-Service: {{ force_email_service }} - -{ - "to_address": "{{email_test_recipient}}", - "from_address": "Sanitation ", - "subject": "Sanitation Confirmation", - "message": "We are pleased to confirm your pickup", - "type": "confirmation", - "senddatetime": "-1 days" -} - -> {% - client.test("Status code is 400", function () { - client.assert(response.status == 400, "Response HTTP Code is not 400"); - }); - client.test("Response is json", function () { - var type = response.contentType.mimeType; - client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); - }); - client.test("Expected Response - Scheduled date is in the past", function () { - client.assert(response.body.response == "Scheduled date is in the past.", "Response status field is not 'Scheduled date is in the past'"); - }); -%} From 4ec3c8fd2496902d8555832e69bbf09d7cabbb5e Mon Sep 17 00:00:00 2001 From: David Upton Date: Thu, 25 Apr 2024 23:51:34 -0400 Subject: [PATCH 09/48] DIG-4317 Refactoring and commenting --- .../modules/bos_email/bos_email.http | 4 - .../src/Controller/EmailController.php | 98 ++++++++++++------- .../bos_email/src/EmailProcessorInterface.php | 2 +- .../bos_email/src/EmailServiceInterface.php | 5 +- .../src/Plugin/EmailProcessor/Commissions.php | 2 +- .../src/Plugin/EmailProcessor/Contactform.php | 2 +- .../Plugin/EmailProcessor/DefaultEmail.php | 2 +- .../MetrolistInitiationForm.php | 2 +- .../MetrolistListingConfirmation.php | 4 +- .../MetrolistListingNotification.php | 2 +- .../src/Plugin/EmailProcessor/Registry.php | 2 +- .../src/Plugin/EmailProcessor/Sanitation.php | 4 +- .../QueueWorker/ContactformProcessItems.php | 49 ++++++---- .../QueueWorker/ScheduledEmailProcessor.php | 83 ++++++++++------ .../bos_email/src/Services/DrupalService.php | 57 ++++------- .../src/Services/PostmarkService.php | 26 ++--- 16 files changed, 187 insertions(+), 157 deletions(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http index 6d60ed3f6a..785a3c2b5f 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http +++ b/docroot/modules/custom/bos_components/modules/bos_email/bos_email.http @@ -816,10 +816,6 @@ Force-Service: {{ force_email_service }} client.assert(response.body.status == "error", "Response status field is not 'error'"); client.assert(response.body.response == "Could not evaluate scheduled date.", "Response message is not 'Could not evaluate scheduled date.'"); }); - client.test("Response contains email id", function () { - client.assert(typeof response.body.id !== "undefined" && response.body.id != "", "Response does not contain an ID field"); - client.global.set("sanitation_email_id", response.body.id); - }); %} ### diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/EmailController.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/EmailController.php index b7b8ae9512..2dfd548bae 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/EmailController.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Controller/EmailController.php @@ -5,6 +5,8 @@ use Boston; use Drupal; use Drupal\bos_email\CobEmail; +use Drupal\bos_email\EmailProcessorInterface; +use Drupal\bos_email\EmailServiceInterface; use Drupal\bos_email\Services\TokenOps; use Drupal\Core\Cache\CacheableJsonResponse; use Drupal\Core\Controller\ControllerBase; @@ -14,7 +16,7 @@ use Drupal\Core\Config\ImmutableConfig; /** - * Postmark class for API. + * Email Controller class for API. * * This token is best used when a client-side javascript code is used to call * the bos_email APIs. @@ -49,7 +51,8 @@ class EmailController extends ControllerBase { public ImmutableConfig $config; /** - * Server hosted / mapped to Postmark. + * Records the group for this request. + * A group is a general classification for emails which use the same processor. * * @var string */ @@ -60,15 +63,32 @@ class EmailController extends ControllerBase { */ public $debug; + /** + * @var EmailServiceInterface $email_service The Email sending service being used. + * + */ private $email_service; + /** + * Errors at the class level. + * + * @var string $error + */ private string $error = ""; - /** @var \Drupal\bos_email\EmailProcessorInterface */ + /** + * @var EmailProcessorInterface The email processor being used + */ private $email_processor; + /** + * @var string $honeypot The honeypot firld for this request. + */ private string $honeypot; + /** + * @var bool $authenticated Flag to indicate if this request has been authenticated yet. + */ private bool $authenticated = FALSE; /** @@ -701,8 +721,10 @@ public function callback(string $service, string $stream) { /** * Creates the email object which will be used by the email service. * - * @param array $emailFields - * The array containing Postmark API needed fieds. + * @param \Drupal\bos_email\CobEmail $email_object + * + * @return bool + * @throws \Exception */ private function verifyEmailObject(CobEmail $email_object) { @@ -743,42 +765,48 @@ private function verifyEmailObject(CobEmail $email_object) { */ private function sendEmail(CobEmail $email) { - /** - * @var $mailobj CobEmail - */ - - // Extract the email object, and validate. + // Validate the email object and extract the email fields array. try { $mailobj = $email->data(); } catch (\Exception $e) {} - if ($email->hasValidationErrors()) { return [ 'status' => 'failed', 'response' => implode(":", $email->getValidationErrors()), ]; - } - // Send the email. - try { - $sent = $this->email_service->sendEmail($mailobj); + // Check if we are able to send the mail. + $sent = FALSE; + if (!$this->config->get("enabled")) { + // The whole email sending thing is shut down. Will still queue the + // emails though. + $this->error = "Emailing temporarily suspended for all emails"; + \Drupal::logger("bos_email:EmailController")->error($this->error); } - catch (\Exception $e) { - Drupal::logger("bos_email:PostmarkService") - ->error($this->email_service->error); - $sent = FALSE; + elseif ($mailobj["server"] && !$this->config->get(strtolower($mailobj["server"]))["enabled"]) { + // Just this EmailProcessor is shut down. Will still queue the emails. + $this->error = "Emailing temporarily suspended for {$mailobj["server"]} emails."; + \Drupal::logger("bos_email:EmailController")->error($this->error); + } + else { + // Send the email. + try { + $this->email_service->sendEmail($mailobj); + $sent = TRUE; + } + catch (\Exception $e) { + EmailController::alertHandler($mailobj, $this->email_service->response(), $this->email_service->response()["http_code"]); + $this->error = $e->getMessage(); + Drupal::logger("bos_email:EmailController")->error($this->error); + } } if (!$sent) { - EmailController::alertHandler($mailobj, $this->email_service->response(), $this->email_service->response()["http_code"]); - Drupal::logger("bos_email:PostmarkService") - ->error($this->email_service->error); - - // Add email data to queue because of Postmark failure. - $mailobj["send_error"] = $this->email_service->error; + // Add email data to queue because of sending failure. + $mailobj["send_error"] = $this->error; $this->addQueueItem($mailobj); Drupal::logger("bos_email:EmailController") @@ -788,7 +816,7 @@ private function sendEmail(CobEmail $email) { } else { - // Message was sent successfully to sender via Postmark. + // Message was sent successfully to recipient. $response_message = self::MESSAGE_SENT; } @@ -851,7 +879,7 @@ public static function alertHandler($item, $response, $http_code, $config = NULL $recipient = $config->get("alerts.recipient") ?? FALSE; if ($recipient) { - // Catch suppressed emails at PostMark + // Catch suppressed emails (at PostMark) if ($config->get("hardbounce.hardbounce") && isset($response["ErrorCode"]) && strtolower($response["ErrorCode"]) == "406") { @@ -861,7 +889,7 @@ public static function alertHandler($item, $response, $http_code, $config = NULL $recipient = $config->get("hardbounce.recipient"); } if (!$mailManager->mail("bos_email", 'hardbounce', $recipient, "en", array_merge($item, $response), NULL, TRUE)) { - Drupal::logger("bos_email:PostmarkService")->warning(t("Email sending from Drupal has failed.")); + Drupal::logger("bos_email:EmailController")->warning(t("Email sending from Drupal has failed.")); } } @@ -872,7 +900,7 @@ public static function alertHandler($item, $response, $http_code, $config = NULL $item["token_type"] = "Authentication Token"; $mailManager = Drupal::service('plugin.manager.mail'); if (!$mailManager->mail("bos_email", 'alerts.token', $recipient, "en", $item, NULL, TRUE)) { - Drupal::logger("bos_email:PostmarkService")->warning(t("Email sending from Drupal has failed.")); + Drupal::logger("bos_email:EmailController")->warning(t("Email sending from Drupal has failed.")); } } @@ -883,18 +911,18 @@ public static function alertHandler($item, $response, $http_code, $config = NULL $item["token_type"] = "Session Token"; $mailManager = Drupal::service('plugin.manager.mail'); if (!$mailManager->mail("bos_email", 'alerts.token', $recipient, "en", $item, NULL, TRUE)) { - Drupal::logger("bos_email:PostmarkService")->warning(t("Email sending from Drupal has failed.")); + Drupal::logger("bos_email:EmailController")->warning(t("Email sending from Drupal has failed.")); } } - // When the token needed by PostMark is invalid + // When the Auth token is incorrect elseif ($config->get("alerts.token") && $error && str_contains($error, "Cannot find token")) { $item["token_type"] = "PostMark Server API Token"; $mailManager = Drupal::service('plugin.manager.mail'); if ($mailManager->mail("bos_email", 'alerts.token', $recipient, "en", $item, NULL, TRUE)) { - Drupal::logger("bos_email:PostmarkService")->warning(t("Email sending from Drupal has failed.")); + Drupal::logger("bos_email:EmailController")->warning(t("Email sending from Drupal has failed.")); } } @@ -904,7 +932,7 @@ public static function alertHandler($item, $response, $http_code, $config = NULL && strtolower($error) == "honeypot") { $mailManager = Drupal::service('plugin.manager.mail'); if (!$mailManager->mail("bos_email", 'alerts.honeypot', $recipient, "en", $item, NULL, TRUE)) { - Drupal::logger("bos_email:PostmarkService")->warning(t("Email sending from Drupal has failed.")); + Drupal::logger("bos_email:EmailController")->warning(t("Email sending from Drupal has failed.")); } } } @@ -916,7 +944,7 @@ public static function alertHandler($item, $response, $http_code, $config = NULL if ($recipient) { $mailManager = Drupal::service('plugin.manager.mail'); if (!$mailManager->mail("bos_email", 'monitor.all', $recipient, "en", array_merge($item, $response), NULL, TRUE)) { - Drupal::logger("bos_email:PostmarkService") + Drupal::logger("bos_email:EmailController") ->warning(t("Email sending from Drupal has failed.")); } } @@ -925,7 +953,7 @@ public static function alertHandler($item, $response, $http_code, $config = NULL // Do dome logging if this is a local dev environment. if (str_contains(Drupal::request()->getHttpHost(), "lndo.site")) { - Drupal::logger("bos_email:PostmarkService") + Drupal::logger("bos_email:EmailController") ->info(" diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailProcessorInterface.php b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailProcessorInterface.php index 64a310c988..82cd41a806 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailProcessorInterface.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailProcessorInterface.php @@ -38,7 +38,7 @@ public static function fetchPayload(Request $request): array; * Creates a message body for incoming emails. * * @param array $emailFields An array containing the fields supplied from a - * Postmark webhook callback. + * webhook callback. * * @return void */ diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php index 9ab310a47e..23c026e13e 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/EmailServiceInterface.php @@ -27,9 +27,10 @@ public function updateEmailObject(CobEmail &$email_object): void; * * @param array $item Containing email fields for the service to send. * - * @returns bool Whether the send was successful or not. + * @returns void + * @throws \Exception */ - public function sendEmail(array $item): bool; + public function sendEmail(array $item): void; /** * Fetches any specific settings for this service. diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Commissions.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Commissions.php index e89497e0ae..38a7988454 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Commissions.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Commissions.php @@ -6,7 +6,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** - * Template class for Postmark API. + * EmailProcessor class for Commissions. */ class Commissions extends DefaultEmail implements EventSubscriberInterface { diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Contactform.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Contactform.php index 5e1a2d568d..51a20eee95 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Contactform.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Contactform.php @@ -9,7 +9,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** - * Template class for Postmark API. + * EmailProcessor class for Contact Form. */ class Contactform extends EmailProcessorBase implements EventSubscriberInterface { diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/DefaultEmail.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/DefaultEmail.php index 89171f79f8..97cdc3fb78 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/DefaultEmail.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/DefaultEmail.php @@ -7,7 +7,7 @@ use Drupal\Component\Utility\Xss; /** - * Template class for Postmark API. + * EmailProcessor class for Default Email. */ class DefaultEmail extends EmailProcessorBase { diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistInitiationForm.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistInitiationForm.php index 1077ac1c21..e05624d2c6 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistInitiationForm.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistInitiationForm.php @@ -7,7 +7,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** - * Template class for Postmark API. + * EmailProcessor class for Metrolist Initiation Form Emails. */ class MetrolistInitiationForm extends EmailProcessorBase implements EventSubscriberInterface { diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingConfirmation.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingConfirmation.php index 7351dfdb9b..051a235f89 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingConfirmation.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingConfirmation.php @@ -3,10 +3,10 @@ namespace Drupal\bos_email\Plugin\EmailProcessor; use Drupal\bos_email\CobEmail; -use EmailProcessorBase; +use Drupal\bos_email\Plugin\EmailProcessor\EmailProcessorBase; /** - * Template class for Postmark API. + * EmailProcessor class for Metrolist Listing Confirmation Emails. */ class MetrolistListingConfirmation extends EmailProcessorBase { diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingNotification.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingNotification.php index 10b0421131..d8cd591447 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingNotification.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/MetrolistListingNotification.php @@ -5,7 +5,7 @@ use Drupal\bos_email\CobEmail; /** - * Template class for Postmark API. + * EmailProcessor class for Metrolist Listing Notification emails. */ class MetrolistListingNotification extends EmailProcessorBase { diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Registry.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Registry.php index 46878f41fe..e67818f964 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Registry.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Registry.php @@ -7,7 +7,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** - * Template class for Postmark API. + * EmailProcessor class for registry emails. */ class Registry extends EmailProcessorBase implements EventSubscriberInterface { diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Sanitation.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Sanitation.php index c4e81b44db..92f72c0195 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Sanitation.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/EmailProcessor/Sanitation.php @@ -9,7 +9,7 @@ use Symfony\Component\HttpFoundation\Request; /** - * Email Processor class for Sanitation Scheduling Emails. + * EmailProcessor class for Sanitation Scheduling Emails. */ class Sanitation extends EmailProcessorBase implements EventSubscriberInterface { @@ -64,7 +64,7 @@ public static function parseEmailFields(array &$payload, CobEmail &$email_object // Do the base email fields processing first. parent::parseEmailFields($payload, $email_object); - // Set up the Postmark template. + // Set up the sanitation template. $template_id = \Drupal::config("bos_email.settings")->get("sanitation.template"); $email_object->setField("TemplateID", $template_id); $email_object->setField("Tag", $payload["type"]); diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php index abad4676cc..dac7a0d105 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ContactformProcessItems.php @@ -8,12 +8,12 @@ use Exception; /** - * Processes emails through Postmark API. + * Queue to hold unsent emails. * * @QueueWorker( * id = "email_contactform", - * title = @Translation("Sends emails through Postmark."), - * cron = {"time" = 15} + * title = @Translation("Holds currently unsent emails."), + * cron = {"time" = 60} * ) */ class ContactformProcessItems extends QueueWorkerBase { @@ -21,45 +21,56 @@ class ContactformProcessItems extends QueueWorkerBase { /** * Process each record. * - * @param mixed $item + * @param mixed $data * The item stored in the queue. */ - public function processItem($item) { + public function processItem($data):void { + try { $config = \Drupal::configFactory()->get("bos_email.settings"); if (!$config->get("q_enabled")) { + // All queue processing is disabled. throw new \Exception("All queues are paused by settings at /admin/config/system/boston/email_services."); } - elseif (!empty($item["server"]) - && !$config->get(strtolower($item["server"]))["q_enabled"]) { - throw new \Exception("The queue for {$item["server"]} is paused by settings at /admin/config/system/boston/email_services."); + elseif (!empty($data["server"]) + && !$config->get(strtolower($data["server"]))["q_enabled"]) { + // Just this queue processing for this EmailProcessor is disabled. + throw new \Exception("The queue for {$data["server"]} is paused by settings at /admin/config/system/boston/email_services."); } - if (!empty($item["send_error"])) { - unset($item["send_error"]); + // Cleanup message before sending. + if (!empty($data["send_error"])) { + unset($data["send_error"]); } - if (!empty($item["service"])) { + // Load the original EmailService. + if (!empty($data["service"])) { try { - $email_ops = new $item["service"]; + $emailService = new $data["service"]; } catch (Exception $e) {} } - - if (!isset($email_ops) || empty($email_ops)) { - // Defaults to Drupal. - $email_ops = new DrupalService(); + if (empty($emailService)) { + // Defaults to Drupal so we can always send. + $emailService = new DrupalService(); } - if (!$email_ops->sendEmail($item)) { - throw new \Exception("There was a problem in {$email_ops->id()}. {$email_ops->error}"); + try { + $emailService->sendEmail($data); + } + catch (Exception $e) { + // Bubble this exception. (the try/catch is not strictly necessary, but + // we can use it to add info for logging.) + \Drupal::logger("contactform")->error($e->getMessage()); + throw new \Exception("There was a problem in {$emailService->id()}. {$e->getMessage()}"); } } catch (\Exception $e) { - \Drupal::logger("contactform")->error($e->getMessage()); + // Throwing an exception here will cause this email to be recycled back + // into the queue for immediate resending. throw new \Exception($e->getMessage()); } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php index 7364b11d49..3ac29f32ed 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Plugin/QueueWorker/ScheduledEmailProcessor.php @@ -9,12 +9,12 @@ use Exception; /** - * Processes emails through Postmark API. + * Processes scheduled emails. * * @QueueWorker( * id = "scheduled_email", * title = @Translation("Queues emails for later sending."), - * cron = {"time" = 15} + * cron = {"time" = 60} * ) */ class ScheduledEmailProcessor extends QueueWorkerBase { @@ -22,67 +22,86 @@ class ScheduledEmailProcessor extends QueueWorkerBase { /** * Process each record. * - * @param mixed $item + * @param mixed $data * The item stored in the queue. */ - public function processItem($item): void { + public function processItem($data): void { try { $config = \Drupal::configFactory()->get("bos_email.settings"); if (!$config->get("q_enabled")) { - throw new \Exception("All queues are paused by settings at /admin/config/system/boston/email_services."); + // All queue processing is disabled. + throw new Exception("All queues are paused by settings at /admin/config/system/boston/email_services."); } - elseif (!empty($item["server"]) - && !$config->get(strtolower($item["server"]))["q_enabled"]) { - throw new \Exception("The queue for {$item["server"]} is paused by settings at /admin/config/system/boston/email_services."); + elseif (!empty($data["server"]) + && !$config->get(strtolower($data["server"]))["q_enabled"]) { + // Just this queue processing for this EmailProcessor is disabled. + throw new Exception("The queue for {$data["server"]} is paused by settings at /admin/config/system/boston/email_services."); } - if (intval($item["senddatetime"]) <= strtotime("Now")) { - if (!empty($item["senddatetime"])) { - unset($item["senddatetime"]); + // Check the scheduled send time for this email. + if (intval($data["senddatetime"]) <= strtotime("Now")) { + // The scheduled date/time has passed, so this email is now eligible to + // be sent. + + // Tidy up the email first. + if (!empty($data["senddatetime"])) { + unset($data["senddatetime"]); } - if (!empty($item["send_date"])) { - unset($item["send_date"]); + if (!empty($data["send_date"])) { + unset($data["send_date"]); } - // load the correct email service. - if (!empty($item["service"])) { + // Load the original EmailService. + if (!empty($data["service"])) { try { - $email_ops = new $item["service"]; + $emailService = new $data["service"]; } catch (Exception $e) {} } - - if (empty($email_ops)) { - // Defaults to Drupal so we can always send an email. - $email_ops = new DrupalService(); + if (empty($emailService)) { + // Defaults to sending via Drupal so that we can always send an email. + $emailService = new DrupalService(); } - // This will throw an error if the mail does not send, and will cause - // the item to remain in the queue. - $send = $email_ops->sendEmail($item); + // This will throw an error if the mail does not send, which will cause + // the selected email to remain in the queue. + // Failures in sending will recycle the email back into this queue, and + // make it eligible for immediate resending. + try { + $emailService->sendEmail($data); + } + catch (Exception $e) { + // Bubble this exception. (the try/catch is not strictly necessary, but + // we can use it to add info for logging.) + \Drupal::logger("contactform")->error($e->getMessage()); + throw new Exception("There was a problem in {$emailService->id()}. {$e->getMessage()}"); + } } else { - // Delay the resending of this email until its scheduled time. + // This email is not ready to be sent yet because the send time has not + // been reached. Throw this error to put the email back into this queue + // and not make it available until its scheduled time. throw new DelayedRequeueException( - intval($item["senddatetime"]) - strtotime("Now"), + intval($data["senddatetime"]) - strtotime("Now"), "Email scheduled for the future." ); } - if (!$send) { - throw new \Exception("There was a problem in {$email_ops->id()}. {$email_ops->error}"); - } - } catch (DelayedRequeueException $e) { + // This has most likely been thrown in the normal course of events because + // it is not the scheduled time to send this email yet. + // Throwing this error will put the email back in this queue and will not + // allow any worker to select it again until the time it should be sent. throw new DelayedRequeueException($e->getDelay(), $e->getMessage()); } - catch (\Exception $e) { - \Drupal::logger("contactform")->error($e->getMessage()); - throw new \Exception($e->getMessage()); + catch (Exception $e) { + // This is a general error, so simply put the email back into this queue + // and mark it eligible for immediate resending. + throw new Exception($e->getMessage()); } } diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php index 9f5eaf578e..ef381baa1c 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/DrupalService.php @@ -8,9 +8,10 @@ use Drupal\Core\Render\Markup; use Drupal\Core\Site\Settings; use Drupal\bos_email\CobEmail; +use Exception; /** - * Postmark class for API. + * EmailService class for sedning via DrupalMail . */ class DrupalService implements EmailServiceInterface { @@ -49,30 +50,17 @@ public function updateEmailObject(CobEmail &$email_object): void { } /** - * Send the email via Postmark. + * Send the email via Drupal. * - * @param \Drupal\bos_email\CobEmail $mailobj The email object + * @param array $item * - * @return array + * @return void + * @throws Exception */ - public function sendEmail(array $item):bool { - - // Check if we are sending out emails. - $config = Drupal::configFactory()->get("bos_email.settings"); - if (!$config->get("enabled")) { - $this->error = "Emailing temporarily suspended for all emails"; - Drupal::logger("bos_email:DrupalService")->error($this->error); - return FALSE; - } - elseif ($item["server"] && !$config->get(strtolower($item["server"]))["enabled"]) { - $this->error = "Emailing temporarily suspended for {$item["server"]} emails."; - Drupal::logger("bos_email:DrupalService")->error($this->error); - return FALSE; - } + public function sendEmail(array $item):void { + + $this->error = NULL; - /** - * @var \Drupal\Core\Mail\MailManager $mailManager - */ try { // Send the email. @@ -81,6 +69,7 @@ public function sendEmail(array $item):bool { $mailManager = Drupal::service('plugin.manager.mail'); $sent = $mailManager->mail("bos_email", $item["server"] , $item["To"], "en", $item, $item["ReplyTo"], TRUE); + // Put something into the response object. $this->response = [ "sent" => $sent ? "True" : "False", ]; @@ -88,25 +77,21 @@ public function sendEmail(array $item):bool { if (!$sent || !$sent["result"]) { if (!empty($params["_error_message"])) { $this->response["error"] = $params["_error_message"]; - throw new \Exception($params["_error_message"]); + $this->error = $this->response["error"]; + throw new Exception($this->error); } else { $this->response["error"] = "Error sending email"; - throw new \Exception("Error sending email."); + $this->error = $this->response["error"]; + throw new Exception($this->error); } } - return TRUE; - } catch (\Exception $e) { - $this->error = $e->getMessage(); - $this->response["error"] = $this->error; - if (Boston::is_local()) { - Drupal::logger("bos_email:DrupalService") - ->info("Queued {$item["server"]}"); - return FALSE; - } + $this->response["error"] = $e->getMessage(); + $this->error = $this->response["error"]; + throw new Exception($this->error); } @@ -117,21 +102,21 @@ public function sendEmail(array $item):bool { */ public function getVars(): array { - $postmark_env = []; + $drupal_env = []; if (getenv('POSTMARK_SETTINGS')) { $get_vars = explode(",", getenv('POSTMARK_SETTINGS')); foreach ($get_vars as $item) { $json = explode(":", $item); if (!empty($json[0]) && !empty($json[1])) { - $postmark_env[$json[0]] = $json[1]; + $drupal_env[$json[0]] = $json[1]; } } } else { - $postmark_env = Settings::get('postmark_settings') ?? []; + $drupal_env = Settings::get('postmark_settings') ?? []; } - return $postmark_env; + return $drupal_env; } /** diff --git a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php index 7bef0a848e..ea7b2de120 100644 --- a/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php +++ b/docroot/modules/custom/bos_components/modules/bos_email/src/Services/PostmarkService.php @@ -9,7 +9,7 @@ use Exception; /** - * Postmark variables for email API. + * EmailService class for sedning via PostMark. */ class PostmarkService extends BosCurlControllerBase implements EmailServiceInterface { @@ -89,21 +89,13 @@ public function updateEmailObject(CobEmail &$email_object): void { /** * Send email to Postmark. + * + * @param array $item + * @throws Exception */ - public function sendEmail(array $item):bool { - - // Check if we are sending out emails. - $config = \Drupal::configFactory()->get("bos_email.settings"); - if (!$config->get("enabled")) { - $this->error = "Emailing temporarily suspended for all emails"; - \Drupal::logger("bos_email:PostmarkService")->error($this->error); - return FALSE; - } - elseif ($item["server"] && !$config->get(strtolower($item["server"]))["enabled"]) { - $this->error = "Emailing temporarily suspended for {$item["server"]} emails."; - \Drupal::logger("bos_email:PostmarkService")->error($this->error); - return FALSE; - } + public function sendEmail(array $item):void { + + $this->error = NULL; try { $server_token = $item["server"] . "_token"; @@ -141,12 +133,10 @@ public function sendEmail(array $item):bool { throw new \Exception("Return Error Code: {$this->response["http_code"]}
HEADERS: {$headers}
PAYLOAD: {$item}
RESPONSE:{$response}"); } - return TRUE; - } catch (Exception $e) { $this->error = $e->getMessage(); - return FALSE; + throw new Exception($this->error); } } From 7a8ece1d01cb06932620ab26b6f31d463e347e9e Mon Sep 17 00:00:00 2001 From: David Upton Date: Thu, 25 Apr 2024 23:55:49 -0400 Subject: [PATCH 10/48] DIG-4317 config ignore bos_email settings --- config/acquia_dev/config_ignore.settings.yml | 1 + config/acquia_stage/config_ignore.settings.yml | 1 + config/local/config_ignore.settings.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/config/acquia_dev/config_ignore.settings.yml b/config/acquia_dev/config_ignore.settings.yml index 05bc18d297..f6a0edefda 100644 --- a/config/acquia_dev/config_ignore.settings.yml +++ b/config/acquia_dev/config_ignore.settings.yml @@ -16,3 +16,4 @@ ignored_config_entities: - slackposter.settings - node_elections.settings - block.block.website_feedback_form + - bos_email.settings diff --git a/config/acquia_stage/config_ignore.settings.yml b/config/acquia_stage/config_ignore.settings.yml index 2bf8e3317c..3b6c28dd4d 100644 --- a/config/acquia_stage/config_ignore.settings.yml +++ b/config/acquia_stage/config_ignore.settings.yml @@ -16,4 +16,5 @@ ignored_config_entities: - slackposter.settings - node_elections.settings - block.block.website_feedback_form + - bos_email.settings diff --git a/config/local/config_ignore.settings.yml b/config/local/config_ignore.settings.yml index e2b24d3ebf..e11836670f 100644 --- a/config/local/config_ignore.settings.yml +++ b/config/local/config_ignore.settings.yml @@ -30,3 +30,4 @@ ignored_config_entities: - node_elections.settings - node_rollcall.settings - slackposter.settings + - bos_email.settings From 4b8e0a26c2767459e8534714e22c142787d37f87 Mon Sep 17 00:00:00 2001 From: David Upton Date: Mon, 29 Apr 2024 22:30:46 -0400 Subject: [PATCH 11/48] DIG-4213 Special Item collection boston.gov page for widget embed --- .../apps/sanitation_scheduling/README.md | 11 +++ .../apps/sanitation_scheduling/composer.json | 39 ++++++++ .../sanitation_scheduling/docker-compose.yml | 37 ++++++++ .../sanitation_scheduling.info.yml | 8 ++ .../sanitation_scheduling.libraries.yml | 19 ++++ .../sanitation_scheduling.module | 30 +++++++ .../modules/bos_web_app/bos_web_app.module | 16 +++- .../modules/bos_web_app/images/spinner.gif | Bin 0 -> 51107 bytes .../modules/bos_web_app/images/spinner_2.gif | Bin 0 -> 19264 bytes .../modules/bos_web_app/readme.md | 84 ++++++++++++++++++ .../templates/paragraph--web-app.html.twig | 24 +++-- .../bos_theme/css/bos_theme_overrides.css | 1 + scripts/deploy/travis_build.sh | 1 + 13 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/README.md create mode 100644 docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/composer.json create mode 100644 docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/docker-compose.yml create mode 100644 docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.info.yml create mode 100644 docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml create mode 100644 docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module create mode 100644 docroot/modules/custom/bos_components/modules/bos_web_app/images/spinner.gif create mode 100644 docroot/modules/custom/bos_components/modules/bos_web_app/images/spinner_2.gif create mode 100644 docroot/modules/custom/bos_components/modules/bos_web_app/readme.md diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/README.md b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/README.md new file mode 100644 index 0000000000..818a95598f --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/README.md @@ -0,0 +1,11 @@ +# Sanitation Scehduling React App + +## Build +From the sanitation_scheduling folder, ensure the command in `docker-compose.yml` is "build" and then run `docker-compose up sanitation`. This will create the necessary dist files in the `sanitation_scheduling/app/frontend/dist/assets` folder. + +## Deploy +### To develop +Commit changes to the files in `sanitation_scheduling/app/frontend/src` into the repository `develop` branch. Github actions will run to build and deploy the app into Google Firebase. This Drupal module does not normally need to be updated or deployed. + +### To Production +Merge the updated `develop` branch into `production`. Github actions will run to build and deploy the app into Google Firebase. This Drupal module does not normally need to be updated. diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/composer.json b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/composer.json new file mode 100644 index 0000000000..eebd0699bb --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/composer.json @@ -0,0 +1,39 @@ +{ + "name": "cob-webapp/sanitation_scheduling", + "description": "React app hosted on Boston.gov allowing residents to schedule Mattress and Bulk item collection.", + "license": "GPL-2.0-or-later", + "type": "project", + "version": "1.0", + "authors": [ + { + "name": "Speedlane", + "role": "Frontend Development Consultancy", + "email": "maroun.melhem@boston.gov" + }, + { + "name": "David Upton", + "role": "Drupal Developer.", + "email": "david.upton@boston.gov" + } + ], + "repositories": [ + { + "type": "package", + "package": { + "name": "bos_web_app/sanitation_scheduling", + "version": "1.02", + "dist": { + "url": "https://github.com/CityOfBoston/sanitation-scheduling/archive/1.0.zip", + "type": "zip" + }, + "type": "drupal-custom-module", + "source": { + "url": "git@github.com:CityOfBoston/sanitation-scheduling.git", + "type": "git", + "reference": "1053dc5" + } + } + } + ] + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/docker-compose.yml b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/docker-compose.yml new file mode 100644 index 0000000000..b45da86743 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.6' + +networks: + cob_docker_network: + driver: bridge + name: cob_docker_network + +services: + sanitation: + build: + context: . + dockerfile: dockerfile + container_name: cob-sanitation + image: 'node:18-bullseye' + networks: + - cob_docker_network + expose: + - 5173 + entrypoint: "/docker-entrypoint.sh" +# command: "run" + command: "build" + environment: + GIT_REPO: "CityOfBoston/sanitation-scheduling.git" + GIT_SSH_CERT_FILE: "/hostuser/.ssh/id_rsa" + GIT_BRANCH: "main" + ports: + - '5173:5173' + volumes: + - ./:/sanitation-scheduler + - ./docker-entrypoint.sh:/docker-entrypoint.sh + - ~/.ssh/known_hosts:/root/.ssh/known_hosts + - ~:/hostuser + labels: + traefik.enable: true + traefik.http.routers.digitalapps.rule: "Host(`sanitation.docker.localhost`)" + traefik.http.routers.digitalapps.entrypoints: "http" + traefik.http.services.digitalapps.loadbalancer.server.port: "5173" diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.info.yml b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.info.yml new file mode 100644 index 0000000000..9f1629a76d --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.info.yml @@ -0,0 +1,8 @@ +name: 'sanitation_scheduling' +type: module +description: 'React app hosted on Boston.gov allowing residents to schedule Mattress and Bulk item collection' +core_version_requirement: ^10 +package: 'Custom' +dependencies: + - bos_web_app +config_devel: { } diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml new file mode 100644 index 0000000000..6396773aa3 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml @@ -0,0 +1,19 @@ +sanitation_scheduling-dev: + version: scheduling.123456 + js: + app/frontend/dist/assets/bundle.js: {preprocess: false, attributes: {type: 'module', id: sanitation-js}} + css: + layout: + app/frontend/dist/assets/index.css: {preprocess: false, attributes: {media: screen, type: text/css}} + dependencies: + - core/drupalSettings + +sanitation_scheduling: + version: scheduling.123456 + js: + https://sanitation-scheduling-dev.web.app/assets/bundle.js: {preprocess: false, attributes: {type: 'module', id: sanitation-js}} + css: + layout: + https://sanitation-scheduling-dev.web.app/assets/index.css: {preprocess: false, attributes: {media: screen, type: text/css}} + dependencies: + - core/drupalSettings diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module new file mode 100644 index 0000000000..b538186d6a --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module @@ -0,0 +1,30 @@ +setAttribute("id", "sanitation-scheduling-app"); + + // This optional command will cause a script function to run after the page + // is loaded. + // Any valid javascript added to $vars["autorun"] will be executed. +// $vars["autorun"] = 'alert("Will load sanitation app");'; + + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module b/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module index c414e12b0e..090284a097 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module @@ -24,13 +24,25 @@ function bos_web_app_theme() { */ function bos_web_app_preprocess_paragraph__web_app(array &$vars) { if (!empty($vars['paragraph'])) { - $paragraph = $vars['paragraph']; + + // Include a library for the bos_web_app module itself. $vars['#attached']['library'][] = 'bos_web_app/bos_web_app'; + + // Legacy, include a library ref for the app name which is defined in the + // bos_web_app module. + // NOTE: Library inclusion should be handled in the apps own module. + $paragraph = $vars['paragraph']; if ($paragraph->hasField('field_app_name')) { + $libraryDiscovery = \Drupal::service('library.discovery'); + $libraries = $libraryDiscovery->getLibrariesByExtension("bos_web_app"); $app_name = strtolower($paragraph->get('field_app_name')->value); $app_name = str_replace(' ', '_', $app_name); - $vars['#attached']['library'][] = 'bos_web_app/' . $app_name; + if (isset($libraries[$app_name])) { + $vars['#attached']['library'][] = 'bos_web_app/' . $app_name; + } } + $vars['module_path'] = \Drupal::service('module_handler')->getModule('bos_web_app')->getPath(); + } } diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/images/spinner.gif b/docroot/modules/custom/bos_components/modules/bos_web_app/images/spinner.gif new file mode 100644 index 0000000000000000000000000000000000000000..2fde2f7f31b17f252040ffd989ed05151bc88ac8 GIT binary patch literal 51107 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 literal 0 HcmV?d00001 diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/images/spinner_2.gif b/docroot/modules/custom/bos_components/modules/bos_web_app/images/spinner_2.gif new file mode 100644 index 0000000000000000000000000000000000000000..2b2a9a4cc57eb9130981d11a0a3e200cdae6d702 GIT binary patch literal 19264 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: 1. The top level name is important and must be the same as the folder name. 2. The version is important. When a new version of the JS app is released, this version number need to be incremented or else Drupal will continue to use the current version it has cached. +```yaml +my_web_app: + version: scheduling.123456 + js: + apps/my_web_app/dist/index.js: {attributes: {type: text/javascript}} + css: + layout: + apps/my_web_app/dist/styles.css: {attributes: {media: screen, type: text/css}} + dependencies: + - core/drupalSettings +``` + +## Configuration updates to bos_web_app +***There should be no need to alter any code in bos_web_app*** + +## Updates to main Drupal `config.json` file. +The `composer.json` needs to be modified to download the JS code from its repository and include it in the docroot for the Drupal site. +1. Need to add 2 repository entries for the GitHub repository to the file. One entry will downoad the js-build (bin) code and one will download the source code. The source code is only needed for local development. +```yaml + { + "type": "package", + "package": { + "name": "cityofboston/my_web_app_dev", + "version": "1.0", + "type": "drupal-custom-module", + "source": { + "url": "git@github.com:CityOfBoston/my-web-app.git", + "type": "git", + "reference": "main" + } + } + }, + { + "type": "package", + "package": { + "name": "cityofboston/my_web_app", + "version": "1.0", + "type": "drupal-custom-module", + "dist": { + "url": "https://github.com/CityOfBoston/my-web-app/archive/1.0.zip", + "type": "zip" + } + } + } +``` +2. Need to add a `require` and `require-dev` entry to include the bin and source code. +```yaml +"require": { + ... + "cityofboston/my_web_app": "^1.0", + ... +} +"require-dev": { + ... + "cityofboston/my_web_app_dev": "^1.0", + ... +} +``` +NOTE: The `composer/installers` extension will install the js into the js folder of the `bos_web_app/apps/my-web-app` folder. + +## Built-in requirements on the JS App side: +1. The JS app should be contained within a City of Boston (or public) GitHub repository +2. **CSS** Should leverage css from the COB patterns library: https://patterns.boston.gov +3. **Initializing** The js should be initialized +2. **Anchoring** Should use a simple anchor, an `div` with an id of `web-app`. +> Should another id be required, then the id can be added to the Drupal module like this: +``` + if ($vars["elements"]["field_app_name"][0]["#context"]["value"] == "[app_name]") { + if (!isset($vars["webapp_anchor"])) { + $vars["webapp_anchor"] = new Drupal\Core\Template\Attribute(); + } + $vars["webapp_anchor"]->setAttribute("id", "[custom-id"); + } +``` diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig b/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig index de177baeb4..1313fa77c8 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig @@ -23,10 +23,24 @@ * @see template_preprocess_entity() * @see template_process() #} -
-
-
-
Loading ...
-
+{% set class="container content entity entity-paragraphs-item paragraphs-item-text component-section" %} +{% if not webapp_anchor %} + {% set webapp_anchor = {'id': 'web-app'} %} +{% endif %} +{% set webapp_attributes = create_attribute(webapp_anchor) %} + +{% if autorun %} + +{% endif %} + +
+
+ +
Loading ...
+
diff --git a/docroot/themes/custom/bos_theme/css/bos_theme_overrides.css b/docroot/themes/custom/bos_theme/css/bos_theme_overrides.css index 6b7a8e6ded..b33b7adeee 100644 --- a/docroot/themes/custom/bos_theme/css/bos_theme_overrides.css +++ b/docroot/themes/custom/bos_theme/css/bos_theme_overrides.css @@ -987,6 +987,7 @@ body.toolbar-vertical.toolbar-tray-open .watermark-wrapper { body.toolbar-horizontal.toolbar-tray-open .watermark-wrapper { top: 4.4em; height: 4.4em; + /* top: 97px; */ } .watermark-wrapper { width: 100%; diff --git a/scripts/deploy/travis_build.sh b/scripts/deploy/travis_build.sh index b087326bdf..4f967fa565 100755 --- a/scripts/deploy/travis_build.sh +++ b/scripts/deploy/travis_build.sh @@ -79,6 +79,7 @@ # Set the Acquia environment variable. if [ ${TRAVIS_BRANCH} == "master" ]; then export AH_SITE_ENVIRONMENT="prod" + # export COMPOSER_NO_DEV=1 # Will stop dev packages from being loaded. else export AH_SITE_ENVIRONMENT="dev" fi From 93b3af00df08144fefce0efd97cb80b480425fee Mon Sep 17 00:00:00 2001 From: David Upton Date: Tue, 30 Apr 2024 16:45:40 -0400 Subject: [PATCH 12/48] Create D10-Deploy.yml --- .github/workflows/D10-Deploy.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/D10-Deploy.yml diff --git a/.github/workflows/D10-Deploy.yml b/.github/workflows/D10-Deploy.yml new file mode 100644 index 0000000000..37b0e521e9 --- /dev/null +++ b/.github/workflows/D10-Deploy.yml @@ -0,0 +1,25 @@ + +# @file: deploy.yml +# This Action builds a deploy artifact (which in this case is a fully populated config, vendor and docroot folder for a +# Dupal website) and commits these artifact folders+files to an Acquia Repository. +# - This Action is fired when a tracked branch has code pushed to it, typically when: +# 1. a PR to the tracked branch is committed, or +# 2. a commit is pushed and merged directly into the tracked branch, or +# 3. (maybe) any other activity which alters the code at the HEAD of the branch or moves the HEAD of the branch. +# - The Acquia Repository is monitored by Acquia and when code merged into a branch which is "attached" to an Acquia +# environment, then the code is first copied onto that environment and then a deployment (set of scripts) initiated. +# City of Boston write the deployment scripts that Acquia runs, but we cannot launch them manually. +# At this time, we are using Acquia "Hooks" and not "Pipelines" to manage the deployment (post-copy activities). + +# Attached resources: +# - GitHub SECRETS: +# -> local: SSH_GITHUB_KEY -> SSH key used to connect to GitHub for remote git operations +# -> local: ACQUIA_SSH_KEY -> SSH key used to connect to Acquia GitLab for remote git operations +# -> local: ACQUIA_REMOTE_REPO_URL -> URL for the Acquia GitLab repository +# -> global: SLACK_DOIT_WEBHOOK_URL -> Webhook URL for posting messages to slack +# - GitHub VARIABLES: +# -> local: SLACK_DRUPAL_CHANNEL -> Channel to post devops messages into + +name: "Deploy to Acquia" +on: + workflow_dispatch: From b9400cd17cd781ce39eb4011375d26b63e556e22 Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 11:39:08 -0400 Subject: [PATCH 13/48] DIG-4213 Theme overrides. --- .../css/sanitation_scheduling.overrides.css | 32 +++++++++++++++++++ .../sanitation_scheduling.libraries.yml | 6 ++++ .../templates/paragraph--web-app.html.twig | 2 +- 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css new file mode 100644 index 0000000000..021f0f87bc --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css @@ -0,0 +1,32 @@ +div#sanitation-scheduling-app { + color: #000; +} +div#sanitation-scheduling-app h1 { + margin: unset; + letter-spacing: unset; + line-height: unset; +} +div#sanitation-scheduling-app h2 { + text-transform: unset; + letter-spacing: unset; + line-height: unset; +} +div#sanitation-scheduling-app h3 { + text-transform: unset; + font-weight: unset; + font-family: unset; + color: unset; + font-size: unset; + letter-spacing: unset; + line-height: initial; +} +div#sanitation-scheduling-app input { + height: initial; + display: initial; +} +div#sanitation-scheduling-app label { + text-transform: unset; + letter-spacing: unset; + line-height: unset; + margin: unset; +} diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml index 6396773aa3..01c8c5e088 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml @@ -3,7 +3,10 @@ sanitation_scheduling-dev: js: app/frontend/dist/assets/bundle.js: {preprocess: false, attributes: {type: 'module', id: sanitation-js}} css: + theme: + //fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap: { preprocess: false, type: external }, layout: + css/sanitation_scheduling.overrides.css: {preprocess: true, attributes: {media: screen, type: text/css}} app/frontend/dist/assets/index.css: {preprocess: false, attributes: {media: screen, type: text/css}} dependencies: - core/drupalSettings @@ -13,7 +16,10 @@ sanitation_scheduling: js: https://sanitation-scheduling-dev.web.app/assets/bundle.js: {preprocess: false, attributes: {type: 'module', id: sanitation-js}} css: + theme: + //fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap: { preprocess: false, type: external }, layout: + css/sanitation_scheduling.overrides.css: {preprocess: true, attributes: {media: screen, type: text/css}} https://sanitation-scheduling-dev.web.app/assets/index.css: {preprocess: false, attributes: {media: screen, type: text/css}} dependencies: - core/drupalSettings diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig b/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig index 1313fa77c8..895e386a4c 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig @@ -40,7 +40,7 @@
-
Loading ...
+
Loading ...
From 67193ee977800a9e34dc18f76a0b996846278593 Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 11:44:51 -0400 Subject: [PATCH 14/48] DIG-4213 Sync back CI_working changes --- .github/config/deploy/deploy-excludes.txt | 58 ++++ .github/config/deploy/deploy-from.txt | 10 + .github/config/slack/slackDeployEnd.yml | 33 +++ .github/config/slack/slackDeployStart.yml | 10 + .github/config/slack/slackPublishEnd.yml | 33 +++ .github/config/slack/slackPublishStart.yml | 10 + .github/workflows/D10-Deploy.yml | 302 +++++++++++++++++++++ 7 files changed, 456 insertions(+) create mode 100644 .github/config/deploy/deploy-excludes.txt create mode 100644 .github/config/deploy/deploy-from.txt create mode 100644 .github/config/slack/slackDeployEnd.yml create mode 100644 .github/config/slack/slackDeployStart.yml create mode 100644 .github/config/slack/slackPublishEnd.yml create mode 100644 .github/config/slack/slackPublishStart.yml create mode 100644 .github/workflows/D10-Deploy.yml diff --git a/.github/config/deploy/deploy-excludes.txt b/.github/config/deploy/deploy-excludes.txt new file mode 100644 index 0000000000..5d65dd8ca9 --- /dev/null +++ b/.github/config/deploy/deploy-excludes.txt @@ -0,0 +1,58 @@ +# IDE generated files +.idea* +.css* +.eslint* +.editorconfig + +# Unwanted files from the repo-root +.lando.yml +build.xml + +# Unwanted files from the docroot +docroot/web.config +docroot/sites/default/files +docroot/example.gitignore + +# Unwanted settings files +docroot/sites/default/settings/settings.terraform.php +docroot/sites/default/settings/settings.travis.php +docroot/sites/default/settings/settings.local.php +docroot/sites/default/settings/private.settings.php +docroot/sites/default/default.services.yml +docroot/sites/default/default.settings.php +docroot/sites/example.settings.local.php +docroot/sites/example.sites.php + +# Acquia hook files that aren't needed +hooks/samples +hooks/templates + +# Drush file +drush/drush.yml + +# Git files +.git +.gitignore +.gitattributes + +# Any package files +package.json +package-lock.json + +# Detailed exclusions for webapps. +# - must add enough of the full path to ensure accurate removals. +node_modules +bos_web_app/apps/**/src +bos_web_app/apps/**/*.html +bos_web_app/apps/**/*.json +bos_web_app/apps/**/postcss*.* +bos_web_app/apps/**/jest*.* +bos_web_app/apps/**/gulp*.* +bos_web_app/apps/**/webpack*.* +bos_web_app/apps/**/babel*.* +bos_web_app/apps/**/favicon.ico +bos_web_app/apps/**/_* +bos_web_app/apps/**/.* + +# COB custom module files which are not required +docroot/modules/custom/**/testing* diff --git a/.github/config/deploy/deploy-from.txt b/.github/config/deploy/deploy-from.txt new file mode 100644 index 0000000000..987032cb6c --- /dev/null +++ b/.github/config/deploy/deploy-from.txt @@ -0,0 +1,10 @@ +config +docroot +drush +hooks +vendor +scripts/deploy/acquia.enc +scripts/deploy/cob_utilities.sh +scripts/composer +composer.lock +composer.json diff --git a/.github/config/slack/slackDeployEnd.yml b/.github/config/slack/slackDeployEnd.yml new file mode 100644 index 0000000000..b0c2a2b745 --- /dev/null +++ b/.github/config/slack/slackDeployEnd.yml @@ -0,0 +1,33 @@ +username: 'Acquia Environment Deploy' +icon_url: https://boston.gov/digitalteamicon.png + +pretext: Deployment of <{{repositoryUrl}}|{{repositoryName}}> reports {{jobStatus}}. +title: <{{workflowRunUrl}}|Build {{#if jobStatus='Success'}}Completed{{else}}INCOMPLETED{{/if}}> +title_link: {{workflowRunUrl}} + +text: | + +fallback: |- + [GitHub] {{payload.repository.name}} finished + +fields: + - title: Job Steps + value: "{{#each jobSteps}}{{icon this.outcome}} {{@key}}\n{{/each}}" + short: false + +footer: >- + {{payload.enterprise.name}}, <{{payload.repository.homepage}}|{{payload.repository.name}}>: _GitHub Action:deploy.yml_#{{runNumber}} + +colors: + info: '#5DADE2' + success: '#0d5c1f' + failure: '#821414' + cancelled: '#7D3C98' + default: '#5DADE2' + +icons: + success: ':white_check_mark:' + failure: ':grimacing:' + cancelled: ':x:' + skipped: ':heavy_minus_sign:' + default: ':interrobang:' diff --git a/.github/config/slack/slackDeployStart.yml b/.github/config/slack/slackDeployStart.yml new file mode 100644 index 0000000000..21f52e5120 --- /dev/null +++ b/.github/config/slack/slackDeployStart.yml @@ -0,0 +1,10 @@ +username: 'Acquia Environment Deploy' +icon_url: https://boston.gov/digitalteamicon.png + +pretext: A deployment to Acquia of the branch {{branch}} from the repository <{{repositoryUrl}}|{{repositoryName}}> has been initiated. +title: <{{workflowRunUrl}}|Deploy Start> +title_link: {{workflowRunUrl}} + +text: | + A GitHub Action has been executed because a {{eventName}} event on the branch {{branch}}. This has started a deploy process for this branch. + diff --git a/.github/config/slack/slackPublishEnd.yml b/.github/config/slack/slackPublishEnd.yml new file mode 100644 index 0000000000..d9e7e5bf3f --- /dev/null +++ b/.github/config/slack/slackPublishEnd.yml @@ -0,0 +1,33 @@ +username: 'Drupal {{branch}} Publish' +icon_url: https://boston.gov/digitalteamicon.png + +pretext: Publish of <{{repositoryUrl}}|{{repositoryName}}> reports {{jobStatus}}. +title: <{{workflowRunUrl}}|Publish {{#if jobStatus='Success'}}Completed{{else}}INCOMPLETED{{/if}}> +title_link: {{workflowRunUrl}} + +text: | + +fallback: |- + [GitHub] {{payload.repository.name}} finished + +fields: + - title: Job Steps + value: "{{#each jobSteps}}{{icon this.outcome}} {{@key}}\n{{/each}}" + short: false + +footer: >- + {{payload.enterprise.name}}, <{{payload.repository.homepage}}|{{payload.repository.name}}>: _GitHub Action:publish.yml_#{{runNumber}} + +colors: + info: '#5DADE2' + success: '#0d5c1f' + failure: '#821414' + cancelled: '#7D3C98' + default: '#5DADE2' + +icons: + success: ':white_check_mark:' + failure: ':grimacing:' + cancelled: ':x:' + skipped: ':heavy_minus_sign:' + default: ':interrobang:' diff --git a/.github/config/slack/slackPublishStart.yml b/.github/config/slack/slackPublishStart.yml new file mode 100644 index 0000000000..60475580a0 --- /dev/null +++ b/.github/config/slack/slackPublishStart.yml @@ -0,0 +1,10 @@ +username: 'Drupal {{branch}} Publish' +icon_url: https://boston.gov/digitalteamicon.png + +pretext: A {{branch}} publish of <{{repositoryUrl}}|{{repositoryName}}> has been initiated. +title: <{{workflowRunUrl}}|Publishing Start> +title_link: {{workflowRunUrl}} + +text: | + A {{eventName}} to {{branch}} has started a <{{workflowRunUrl}}|GitHub Action> to sync the public repository and + create release notes in both the <{{repositoryUrl}}|private> and repositories. diff --git a/.github/workflows/D10-Deploy.yml b/.github/workflows/D10-Deploy.yml new file mode 100644 index 0000000000..ab8b708865 --- /dev/null +++ b/.github/workflows/D10-Deploy.yml @@ -0,0 +1,302 @@ +# @file: deploy.yml +# This Action builds a deploy artifact (which in this case is a fully populated config, vendor and docroot folder for a +# Dupal website) and commits these artifact folders+files to an Acquia Repository. +# - This Action is fired when a tracked branch has code pushed to it, typically when: +# 1. a PR to the tracked branch is committed, or +# 2. a commit is pushed and merged directly into the tracked branch, or +# 3. (maybe) any other activity which alters the code at the HEAD of the branch or moves the HEAD of the branch. +# - The Acquia Repository is monitored by Acquia and when code merged into a branch which is "attached" to an Acquia +# environment, then the code is first copied onto that environment and then a deployment (set of scripts) initiated. +# City of Boston write the deployment scripts that Acquia runs, but we cannot launch them manually. +# At this time, we are using Acquia "Hooks" and not "Pipelines" to manage the deployment (post-copy activities). + +# Attached resources: +# - GitHub SECRETS: +# -> local: SSH_GITHUB_KEY -> SSH key used to connect to GitHub for remote git operations +# -> local: ACQUIA_SSH_KEY -> SSH key used to connect to Acquia GitLab for remote git operations +# -> local: ACQUIA_REMOTE_REPO_URL -> URL for the Acquia GitLab repository +# -> local: PRIVATE_REPO_URL -> The private repo to merge into the tracked repo +# -> global: SLACK_DOIT_WEBHOOK_URL -> Webhook URL for posting messages to slack +# - GitHub VARIABLES: +# -> local: SLACK_DRUPAL_CHANNEL -> Channel to post devops messages into + +name: "Deploy to Acquia" +on: + workflow_dispatch: + # push: + # branches: # we can add branches to this list which will deploy code to Acquia GitLab as we push code to those branches. + # - dummy + # - develop + # - stage + # - production + # - CI_working + # - DEV2_working + # - UAT_working + +env: + ACQUIA_BRANCH: ${{ github.ref_name }}-deploy # the branch name to be used in the Acquia Repo + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DOIT_WEBHOOK_URL }} # for slack + DEBUG: 1 # =1 for debug output, else normal running =0 + DRY_RUN: 0 # =1 to stop any updates to remote repos, else =0 + +jobs: + Deploy: + # installed software: https://github.com/actions/runner-images/blob/main/images/linux/Ubuntu2204-Readme.md + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + + - name: Post to Slack + uses: act10ns/slack@v2.0.0 + with: + status: Starting + channel: ${{ vars.SLACK_MONITORING_CHANNEL }} + + - name: Output some debugging info + if: ${{ env.DEBUG == 1 }} + run: | + export + pwd + + # Install some dependencies. + - name: Install additional Linux packages + run: | + sudo add-apt-repository ppa:ondrej/php + sudo apt-get update + sudo apt-get install -y -q libgd3 php-gd php-curl libpng-dev libjpeg-dev libwebp-dev + + # More debugging (full PHP configuration) + - name: Output more debugging info + if: ${{ env.DEBUG == 1 }} + run: | + php -i + + # checkout the cob repository that has been pushed to. + - name: Checkout the repository + uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + ssh-key: ${{ secrets.SSH_GITHUB_KEY }} + persist-credentials: false # otherwise, the token used is the GITHUB_TOKEN, instead of your personal token + fetch-depth: 1 # 0 = all, otherwise, you will fail to push refs to dest repo + path: candidate # Checkout into this folder + + # checkout the private repository which has settings and secrets etc + - name: Checkout the private repository + uses: actions/checkout@v4 + with: + repository: ${{ secrets.PRIVATE_REPO_URL }} + ssh-key: ${{ secrets.SSH_GITHUB_KEY }} + ref: develop + persist-credentials: false # otherwise, the token used is the GITHUB_TOKEN, instead of your personal token + fetch-depth: 1 # 0 = all, otherwise, you will fail to push refs to dest repo + path: private # Checkout into this folder + + # Merge the private repo into the tracked repo + - name: Merge the private repo files + run: | + rm -rf ./private/.git + du ./private + ls -la ./private/docroot/sites/default/settings + find ./private/. -iname '*..gitignore' -exec rename 's/\.\.gitignore/\.gitignore/' '{}' \; + rsync -aE ./private/ ./candidate/ --exclude=*.md + rm -rf ./private + ls -la ./candidate/docroot/sites/default/settings + + # Cache Composer Dependencies + - name: Cache Composer dependencies + uses: actions/cache@v3 + with: + path: /tmp/composer-cache + key: ${{ runner.os }}-${{ hashFiles('**/composer.lock') }} + + # Composer Install: Note: has an SSH option for private repos + - name: Download Drupal and dependencies + id: Build-drupal-using-Composer + uses: php-actions/composer@v6 + env: + USE_DEV: "yes" + with: + dev: ${{ env.USE_DEV }} # download dev packages when used on dev environments + args: --prefer-dist --no-interaction --no-progress --ignore-platform-req=ext-gd --ignore-platform-req=ext-soap + working_dir: candidate + version: 2.5.8 + php_version: 8.3 + + # Composer drupal:scaffold + - name: Install Drupal Scaffold + uses: php-actions/composer@v6 + with: + command: drupal:scaffold # Add drupal scaffolding files + working_dir: candidate + + - name: Directory listings for completed build + if: ${{ env.DEBUG == 1 }} + run: | + echo "Directory Tree for $(pwd)/candidate" && du ./candidate + # echo "Directory Listing for $(pwd)/candidate" && ls -lAh --group-directories-first candidate + # echo "Directory Listing for $(pwd)/candidate/docroot" && ls -lAh --group-directories-first candidate/docroot + # echo "Directory Listing for $(pwd)/candidate/docroot/modules/contrib" && ls -lAh --group-directories-first candidate/docroot/modules/contrib + # echo "Directory Listing for $(pwd)/candidate/docroot/sites/default" && ls -lAh --group-directories-first candidate/docroot/sites/default + # echo "Directory Listing for $(pwd)/candidate/docroot/sites/default/settings" && ls -lAh --group-directories-first candidate/docroot/sites/default/settings + + # checkout the acquia repository to push to. + - name: Checkout the Acquia repository + id: Checkout-Acquia-Repo + run: | + acquia_ssh_key_path="${HOME}/.ssh" + acquia_ssh_key_file="${HOME}/.ssh/id_rsa" + [[ ${{ env.DEBUG }} == 1 ]] && echo "ssh-key-file: $acquia_ssh_key_file" && echo "ssh-key-path: $acquia_ssh_key_path" + mkdir -p $acquia_ssh_key_path + echo "${{ secrets.ACQUIA_SSH_KEY }}" > "$acquia_ssh_key_file" + chmod 600 $acquia_ssh_key_file + + echo "::notice file=deploy.yml,line=93,title=Success::Drupal codebase was built." + err="" + git config --global --add core.sshCommand "ssh -i $acquia_ssh_key_file" + host=$(echo ${{ secrets.ACQUIA_REMOTE_REPO_URL }} | awk -F'@' '{print $2}' | awk -F':' '{print $1}') || echo "::warning file=deploy.yml,title=Warning::Problem saving known host." + if [[ "$host" != "github.com" ]]; then + echo $(ssh-keyscan -t rsa $host) >> "${HOME}/.ssh/known_hosts" && echo "Host added to ssh known_hosts" || echo "::warning file=deploy.yml,title=Warning::Problem saving known host ($host)." + fi + git config --global user.email "digital-dev@boston.gov" + git config --global user.name ${{ github.triggering_actor }} + git config --global init.defaultBranch ${{ env.ACQUIA_BRANCH }} + + mkdir remote + cd remote + git init && git remote add acquia ${{ secrets.ACQUIA_REMOTE_REPO_URL }} || err="$err: Problem setting remote ref" + git config --local gc.auto 0 || echo "::warning file=deploy.yml,title=Warning::Problem disabling garbage collection (not fatal)." + git -c protocol.version=2 fetch --no-progress --depth=1 --prune --no-recurse-submodules acquia +refs/heads/*:refs/remotes/acquia/* || err="$err: Problem fetching remote branches" + [[ $(git branch --remotes --list acquia/${{ env.ACQUIA_BRANCH }}) == "" ]] && newbranch=1 || newbranch=0 + if [[ $newbranch == 0 ]]; then + git checkout --no-progress --force -B ${{ env.ACQUIA_BRANCH }} refs/remotes/acquia/${{ env.ACQUIA_BRANCH }} || err="$err: Problem checking out ${{ env.ACQUIA_BRANCH }}" + git merge refs/remotes/acquia/${{ env.ACQUIA_BRANCH }} || err="$err: Problem merging ${{ env.ACQUIA_BRANCH }}" + else + git checkout --no-progress --force -B ${{ env.ACQUIA_BRANCH }} || err="$err: Problem creating a new branch" + fi + if [[ ${{ env.DEBUG }} == 1 ]]; then + [[ $newbranch == 0 ]] && git log -1 --format='%H' + echo "Directory Listing for $(pwd)" && ls -lAh + fi + cd ../ + + echo "NEW_BRANCH=$newbranch" >> "${GITHUB_ENV}" + + if [[ "$err" != "" ]]; then + echo "::error file=deploy.yml,title=Error,line=120::$err" + exit 1 + fi + + rm -f .git/gc.log + echo "::notice file=deploy.yml,line=120,title=Success::Remote/Acquia repository was checked out." + + # Prepare candidate + - name: Prepare the candidate for pushing to Acquia + id: Prepare-Deploy-Candidate + env: + deploy_from_file: candidate/.github/config/deploy/deploy-from.txt + deploy_excludes_file: candidate/.github/config/deploy/deploy-excludes.txt + run: | + find candidate -type f -regex '\.gitignore$' -delete + find candidate -type f -regex '\.\.gitignore$' -delete + find candidate/docroot/sites/default/ -type f -iregex 'candidate/docroot/.*/default\..*' -delete + find candidate/docroot/sites/default/ -type f -iregex 'candidate/docroot/.*/example\..*' -delete + rsync -rlDWz --delete-after --files-from=${deploy_from_file} --exclude-from=${deploy_excludes_file} candidate remote + printf "docroot/modules/**/.git\ndocroot/libraries/**/.git\n" > remote/.gitignore + cd candidate && mergemsg=$(git log -1 --oneline ${{ github.sha }}) && cd ../ + $(grep -qi 'hotfix' <<< '${mergemsg}') && echo '[NOTICE] HotFix detected' || ([[ ${{ env.DEBUG }} == 1 ]] && echo "mergemsg=${mergemsg}") + $(grep -qi 'hotfix' <<< '${mergemsg}') && touch remote/.hotfix || rm -f remote/.hotfix + echo "MERGEMSG=$mergemsg" >> "$GITHUB_ENV" + echo "SHORTSHA=${GITHUB_SHA::8}...${GITHUB_SHA: -4}" >> "$GITHUB_ENV" + + - name: Directory listings for finalized candidate + if: ${{ env.DEBUG == 1 }} + run: | + echo "Directory Listing for $(pwd)/remote" && ls -lAh --group-directories-first remote + echo "Directory Listing for $(pwd)/remote/docroot" && ls -lAh --group-directories-first remote/docroot + echo "Directory Listing for $(pwd)/remote/docroot/modules/contrib" && ls -lAh --group-directories-first remote/docroot/modules/contrib + echo "Directory Listing for $(pwd)/remote/docroot/sites/default" && ls -lAh --group-directories-first remote/docroot/sites/default + echo "Directory Listing for $(pwd)/remote/docroot/sites/default/settings" && ls -lAh --group-directories-first remote/docroot/sites/default/settings + + # push to the acquia repository. + - name: PUSH to Acquia + id: Push-Candidate-to-Acquia + env: + commitmsg: "Github PUSH (${{ github.ref_name && github.ref_name || 'develop' }}-${{ env.SHORTSHA }}) by ${{ github.triggering_actor }}: ${{ env.MERGEMSG }}." + run: | + echo "Triggering commit from ${{ github.repository }}:${{ github.ref_name }}" + echo " -> ${{ env.MERGEMSG }}" + host=$(echo ${{ secrets.ACQUIA_REMOTE_REPO_URL }} | awk -F'@' '{print $2}' | awk -F':' '{print $1}') || host="" + echo "Deploy commit into $host:${{ env.ACQUIA_BRANCH }}" + echo " -> ${{ env.commitmsg }}" + + err="" + cd remote + git submodule deinit --all || err="$err: Could not de-initialize submodules" + if [[ ${{ env.DEBUG }} == 1 ]]; then + echo "Working Tree Status (pre-add&commit):" + git status + fi + git add --all && echo ' ' || err="$err: Failed to add changed files" + [[ ${{ env.DEBUG }} == 1 ]] && commitopt="--status" || commitopt="--quiet" + res=$(git commit -m '${{ env.commitmsg }}' $commitopt) || err="$err: Problem committing changes" + if [[ ${{ env.DEBUG }} == 1 ]]; then + echo "Working Tree Status (post-add&commit):" + git status + fi + pushopts="" + if [[ ${{ env.DEBUG }} == 1 ]]; then + echo "Commit results:" && echo $res + pushopts="--verbose" + fi + if [[ ${{ env.DRY_RUN }} == 1 ]]; then + pushopts="$pushopts --dry-run" + echo "::notice file=deploy.yml,title=DRY-RUN::DRY_RUN envar set. Any commits will not be pushed to Acquia." + fi + if [[ $(echo "$res" | grep "nothing to commit") == "" ]]; then + echo "changes=1" >> "$GITHUB_OUTPUT" + echo "git push --set-upstream acquia ${{ env.ACQUIA_BRANCH }}:${{ env.ACQUIA_BRANCH }} ${pushopts}" + git push --set-upstream acquia ${{ env.ACQUIA_BRANCH }}:${{ env.ACQUIA_BRANCH }} ${pushopts} || err="$err: Problem pushing changes to Acquia" + if [[ ${{ env.DRY_RUN }} == 0 ]]; then + echo "::notice file=deploy.yml,title=Success::Remote/Acquia repository was updated- check Acquia for deploy status." + else + echo "::notice file=deploy.yml,title=Success::Remote/Acquia repository was not updatedbecause htis was a dry-run." + fi + else + echo "changes=0" >> "$GITHUB_OUTPUT" + echo "::notice file=deploy.yml,title=No Changes::No changes were found to be pushed to Acquia." + fi + rm -rf $aquia_ssh_key_path + if [[ "$err" != "" ]]; then + echo "::error file=deploy.yml,title=Error,line=213::$err" + exit 1 + fi + + - name: Post to Slack - failure + uses: act10ns/slack@v2.0.0 + if: ${{ failure() }} + with: + status: ${{ job.status }} + steps: ${{ toJson(steps) }} + channel: ${{ vars.SLACK_MONITORING_CHANNEL }} + message: There were issues with the deploy please check the github action {{workflowRunUrl}} + + - name: Post to Slack - success + uses: act10ns/slack@v2.0.0 + if: ${{ success() && steps.Push-Candidate-to-Acquia.outputs.changed == 1 }} + with: + status: ${{ job.status }} + steps: ${{ toJson(steps) }} + channel: ${{ vars.SLACK_MONITORING_CHANNEL }} + config: candidate/.github/config/slack/slackDeployEnd.yml + + - name: Post to Slack - nothing done + uses: act10ns/slack@v2.0.0 + if: ${{ steps.Push-Candidate-to-Acquia.outputs.changed == 0 }} + with: + status: ${{ job.status }} + steps: ${{ toJson(steps) }} + channel: ${{ vars.SLACK_MONITORING_CHANNEL }} + message: There were no changes to upload to Acquia From eb8e862ed21416a6139ad6714436adeb2e65ac7f Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 11:52:34 -0400 Subject: [PATCH 15/48] DIG-4213 Moves env vars to github variables --- .github/workflows/D10-Deploy.yml | 44 +++++++++++++++----------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/.github/workflows/D10-Deploy.yml b/.github/workflows/D10-Deploy.yml index ab8b708865..d761bc6a57 100644 --- a/.github/workflows/D10-Deploy.yml +++ b/.github/workflows/D10-Deploy.yml @@ -36,8 +36,6 @@ on: env: ACQUIA_BRANCH: ${{ github.ref_name }}-deploy # the branch name to be used in the Acquia Repo SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DOIT_WEBHOOK_URL }} # for slack - DEBUG: 1 # =1 for debug output, else normal running =0 - DRY_RUN: 0 # =1 to stop any updates to remote repos, else =0 jobs: Deploy: @@ -47,15 +45,15 @@ jobs: run: shell: bash steps: - + - name: Post to Slack uses: act10ns/slack@v2.0.0 with: status: Starting channel: ${{ vars.SLACK_MONITORING_CHANNEL }} - + - name: Output some debugging info - if: ${{ env.DEBUG == 1 }} + if: ${{ vars.DEPLOY_DEBUG == 1 }} run: | export pwd @@ -69,7 +67,7 @@ jobs: # More debugging (full PHP configuration) - name: Output more debugging info - if: ${{ env.DEBUG == 1 }} + if: ${{ vars.DEPLOY_DEBUG == 1 }} run: | php -i @@ -101,8 +99,8 @@ jobs: du ./private ls -la ./private/docroot/sites/default/settings find ./private/. -iname '*..gitignore' -exec rename 's/\.\.gitignore/\.gitignore/' '{}' \; - rsync -aE ./private/ ./candidate/ --exclude=*.md - rm -rf ./private + rsync -aE ./private/ ./candidate/ --exclude=*.md + rm -rf ./private ls -la ./candidate/docroot/sites/default/settings # Cache Composer Dependencies @@ -133,7 +131,7 @@ jobs: working_dir: candidate - name: Directory listings for completed build - if: ${{ env.DEBUG == 1 }} + if: ${{ vars.DEPLOY_DEBUG == 1 }} run: | echo "Directory Tree for $(pwd)/candidate" && du ./candidate # echo "Directory Listing for $(pwd)/candidate" && ls -lAh --group-directories-first candidate @@ -148,7 +146,7 @@ jobs: run: | acquia_ssh_key_path="${HOME}/.ssh" acquia_ssh_key_file="${HOME}/.ssh/id_rsa" - [[ ${{ env.DEBUG }} == 1 ]] && echo "ssh-key-file: $acquia_ssh_key_file" && echo "ssh-key-path: $acquia_ssh_key_path" + [[ ${{ vars.DEPLOY_DEBUG }} == 1 ]] && echo "ssh-key-file: $acquia_ssh_key_file" && echo "ssh-key-path: $acquia_ssh_key_path" mkdir -p $acquia_ssh_key_path echo "${{ secrets.ACQUIA_SSH_KEY }}" > "$acquia_ssh_key_file" chmod 600 $acquia_ssh_key_file @@ -176,7 +174,7 @@ jobs: else git checkout --no-progress --force -B ${{ env.ACQUIA_BRANCH }} || err="$err: Problem creating a new branch" fi - if [[ ${{ env.DEBUG }} == 1 ]]; then + if [[ ${{ vars.DEPLOY_DEBUG }} == 1 ]]; then [[ $newbranch == 0 ]] && git log -1 --format='%H' echo "Directory Listing for $(pwd)" && ls -lAh fi @@ -206,13 +204,13 @@ jobs: rsync -rlDWz --delete-after --files-from=${deploy_from_file} --exclude-from=${deploy_excludes_file} candidate remote printf "docroot/modules/**/.git\ndocroot/libraries/**/.git\n" > remote/.gitignore cd candidate && mergemsg=$(git log -1 --oneline ${{ github.sha }}) && cd ../ - $(grep -qi 'hotfix' <<< '${mergemsg}') && echo '[NOTICE] HotFix detected' || ([[ ${{ env.DEBUG }} == 1 ]] && echo "mergemsg=${mergemsg}") + $(grep -qi 'hotfix' <<< '${mergemsg}') && echo '[NOTICE] HotFix detected' || ([[ ${{ vars.DEPLOY_DEBUG }} == 1 ]] && echo "mergemsg=${mergemsg}") $(grep -qi 'hotfix' <<< '${mergemsg}') && touch remote/.hotfix || rm -f remote/.hotfix echo "MERGEMSG=$mergemsg" >> "$GITHUB_ENV" echo "SHORTSHA=${GITHUB_SHA::8}...${GITHUB_SHA: -4}" >> "$GITHUB_ENV" - name: Directory listings for finalized candidate - if: ${{ env.DEBUG == 1 }} + if: ${{ vars.DEPLOY_DEBUG == 1 }} run: | echo "Directory Listing for $(pwd)/remote" && ls -lAh --group-directories-first remote echo "Directory Listing for $(pwd)/remote/docroot" && ls -lAh --group-directories-first remote/docroot @@ -235,23 +233,23 @@ jobs: err="" cd remote git submodule deinit --all || err="$err: Could not de-initialize submodules" - if [[ ${{ env.DEBUG }} == 1 ]]; then - echo "Working Tree Status (pre-add&commit):" + if [[ ${{ vars.DEPLOY_DEBUG }} == 1 ]]; then + echo "Working Tree Status (pre-add&commit):" git status - fi + fi git add --all && echo ' ' || err="$err: Failed to add changed files" - [[ ${{ env.DEBUG }} == 1 ]] && commitopt="--status" || commitopt="--quiet" + [[ ${{ vars.DEPLOY_DEBUG }} == 1 ]] && commitopt="--status" || commitopt="--quiet" res=$(git commit -m '${{ env.commitmsg }}' $commitopt) || err="$err: Problem committing changes" - if [[ ${{ env.DEBUG }} == 1 ]]; then - echo "Working Tree Status (post-add&commit):" + if [[ ${{ vars.DEPLOY_DEBUG }} == 1 ]]; then + echo "Working Tree Status (post-add&commit):" git status - fi + fi pushopts="" - if [[ ${{ env.DEBUG }} == 1 ]]; then + if [[ ${{ vars.DEPLOY_DEBUG }} == 1 ]]; then echo "Commit results:" && echo $res pushopts="--verbose" fi - if [[ ${{ env.DRY_RUN }} == 1 ]]; then + if [[ ${{ vars.DEPLOY_DRY_RUN }} == 1 ]]; then pushopts="$pushopts --dry-run" echo "::notice file=deploy.yml,title=DRY-RUN::DRY_RUN envar set. Any commits will not be pushed to Acquia." fi @@ -259,7 +257,7 @@ jobs: echo "changes=1" >> "$GITHUB_OUTPUT" echo "git push --set-upstream acquia ${{ env.ACQUIA_BRANCH }}:${{ env.ACQUIA_BRANCH }} ${pushopts}" git push --set-upstream acquia ${{ env.ACQUIA_BRANCH }}:${{ env.ACQUIA_BRANCH }} ${pushopts} || err="$err: Problem pushing changes to Acquia" - if [[ ${{ env.DRY_RUN }} == 0 ]]; then + if [[ ${{ vars.DEPLOY_DRY_RUN }} == 0 ]]; then echo "::notice file=deploy.yml,title=Success::Remote/Acquia repository was updated- check Acquia for deploy status." else echo "::notice file=deploy.yml,title=Success::Remote/Acquia repository was not updatedbecause htis was a dry-run." From baf2eb5e34fe86f9b7eb7d31999c49da15797935 Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 11:59:10 -0400 Subject: [PATCH 16/48] DIG-4213 Fixes finished slack notification. --- .github/workflows/D10-Deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/D10-Deploy.yml b/.github/workflows/D10-Deploy.yml index d761bc6a57..5d699530f3 100644 --- a/.github/workflows/D10-Deploy.yml +++ b/.github/workflows/D10-Deploy.yml @@ -283,7 +283,7 @@ jobs: - name: Post to Slack - success uses: act10ns/slack@v2.0.0 - if: ${{ success() && steps.Push-Candidate-to-Acquia.outputs.changed == 1 }} + if: ${{ success() && steps.Push-Candidate-to-Acquia.outputs.changes == 1 }} with: status: ${{ job.status }} steps: ${{ toJson(steps) }} @@ -292,7 +292,7 @@ jobs: - name: Post to Slack - nothing done uses: act10ns/slack@v2.0.0 - if: ${{ steps.Push-Candidate-to-Acquia.outputs.changed == 0 }} + if: ${{ steps.Push-Candidate-to-Acquia.outputs.changes == 0 }} with: status: ${{ job.status }} steps: ${{ toJson(steps) }} From 41477e346c141b793188317879cebbfbe6ff1d3f Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 12:58:28 -0400 Subject: [PATCH 17/48] DIG-4213 Fixes final slack message --- .github/workflows/D10-Deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/D10-Deploy.yml b/.github/workflows/D10-Deploy.yml index 5d699530f3..dd01b5050a 100644 --- a/.github/workflows/D10-Deploy.yml +++ b/.github/workflows/D10-Deploy.yml @@ -283,7 +283,7 @@ jobs: - name: Post to Slack - success uses: act10ns/slack@v2.0.0 - if: ${{ success() && steps.Push-Candidate-to-Acquia.outputs.changes == 1 }} + if: ${{ steps.Push-Candidate-to-Acquia.outputs.changes == 1 }} with: status: ${{ job.status }} steps: ${{ toJson(steps) }} From 5edf661cffb79ea52e11eec774340a35ab35a5de Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 13:31:04 -0400 Subject: [PATCH 18/48] DIG-4213 Fix final slack message in template --- .github/config/slack/slackDeployEnd.yml | 2 +- .github/workflows/D10-Deploy.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/config/slack/slackDeployEnd.yml b/.github/config/slack/slackDeployEnd.yml index b0c2a2b745..46e3f1e680 100644 --- a/.github/config/slack/slackDeployEnd.yml +++ b/.github/config/slack/slackDeployEnd.yml @@ -2,7 +2,7 @@ username: 'Acquia Environment Deploy' icon_url: https://boston.gov/digitalteamicon.png pretext: Deployment of <{{repositoryUrl}}|{{repositoryName}}> reports {{jobStatus}}. -title: <{{workflowRunUrl}}|Build {{#if jobStatus='Success'}}Completed{{else}}INCOMPLETED{{/if}}> +title: <{{workflowRunUrl}}|Build {{#ifeq jobStatus "Success"}}Completed{{else}}INCOMPLETED{{/ifeq}}> title_link: {{workflowRunUrl}} text: | diff --git a/.github/workflows/D10-Deploy.yml b/.github/workflows/D10-Deploy.yml index dd01b5050a..edd704b256 100644 --- a/.github/workflows/D10-Deploy.yml +++ b/.github/workflows/D10-Deploy.yml @@ -260,7 +260,7 @@ jobs: if [[ ${{ vars.DEPLOY_DRY_RUN }} == 0 ]]; then echo "::notice file=deploy.yml,title=Success::Remote/Acquia repository was updated- check Acquia for deploy status." else - echo "::notice file=deploy.yml,title=Success::Remote/Acquia repository was not updatedbecause htis was a dry-run." + echo "::notice file=deploy.yml,title=Success::Remote/Acquia repository was not updated because this was a dry-run." fi else echo "changes=0" >> "$GITHUB_OUTPUT" @@ -283,7 +283,7 @@ jobs: - name: Post to Slack - success uses: act10ns/slack@v2.0.0 - if: ${{ steps.Push-Candidate-to-Acquia.outputs.changes == 1 }} + if: ${{ success() && steps.Push-Candidate-to-Acquia.outputs.changes == 1 }} with: status: ${{ job.status }} steps: ${{ toJson(steps) }} From e7ec60f4072d09a8765cfd822a406c918cc3c049 Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 13:57:05 -0400 Subject: [PATCH 19/48] DIG-4213 Fix final slack message in template --- .github/config/slack/slackDeployEnd.yml | 2 +- .github/workflows/D10-Deploy.yml | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/config/slack/slackDeployEnd.yml b/.github/config/slack/slackDeployEnd.yml index 46e3f1e680..4837eb030e 100644 --- a/.github/config/slack/slackDeployEnd.yml +++ b/.github/config/slack/slackDeployEnd.yml @@ -2,7 +2,7 @@ username: 'Acquia Environment Deploy' icon_url: https://boston.gov/digitalteamicon.png pretext: Deployment of <{{repositoryUrl}}|{{repositoryName}}> reports {{jobStatus}}. -title: <{{workflowRunUrl}}|Build {{#ifeq jobStatus "Success"}}Completed{{else}}INCOMPLETED{{/ifeq}}> +title: <{{workflowRunUrl}}|Deploy {{#ifeq jobStatus "SUCCESS"}}Completed{{else}}INCOMPLETED{{/ifeq}}> title_link: {{workflowRunUrl}} text: | diff --git a/.github/workflows/D10-Deploy.yml b/.github/workflows/D10-Deploy.yml index edd704b256..b15311dce1 100644 --- a/.github/workflows/D10-Deploy.yml +++ b/.github/workflows/D10-Deploy.yml @@ -272,29 +272,30 @@ jobs: exit 1 fi - - name: Post to Slack - failure + - name: Post to Slack - success uses: act10ns/slack@v2.0.0 - if: ${{ failure() }} + if: ${{ success() && steps.Push-Candidate-to-Acquia.outputs.changes == 1 }} with: status: ${{ job.status }} steps: ${{ toJson(steps) }} channel: ${{ vars.SLACK_MONITORING_CHANNEL }} - message: There were issues with the deploy please check the github action {{workflowRunUrl}} + config: candidate/.github/config/slack/slackDeployEnd.yml - - name: Post to Slack - success + - name: Post to Slack - nothing done uses: act10ns/slack@v2.0.0 - if: ${{ success() && steps.Push-Candidate-to-Acquia.outputs.changes == 1 }} + if: ${{ success() && steps.Push-Candidate-to-Acquia.outputs.changes == 0 }} with: status: ${{ job.status }} steps: ${{ toJson(steps) }} channel: ${{ vars.SLACK_MONITORING_CHANNEL }} - config: candidate/.github/config/slack/slackDeployEnd.yml + message: There were no changes to upload to Acquia - - name: Post to Slack - nothing done + + - name: Post to Slack - failure uses: act10ns/slack@v2.0.0 - if: ${{ steps.Push-Candidate-to-Acquia.outputs.changes == 0 }} + if: ${{ failure() }} with: status: ${{ job.status }} steps: ${{ toJson(steps) }} channel: ${{ vars.SLACK_MONITORING_CHANNEL }} - message: There were no changes to upload to Acquia + message: There were issues with the deploy please check the github action {{workflowRunUrl}} From d45e47089ec078e087d17e477efa062cd1e1d351 Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 14:13:50 -0400 Subject: [PATCH 20/48] DIG-4213 configs for sanitation --- config/default/core.extension.yml | 1 + config/default/field.field.node.article.field_components.yml | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/config/default/core.extension.yml b/config/default/core.extension.yml index 662557847a..117a49d818 100644 --- a/config/default/core.extension.yml +++ b/config/default/core.extension.yml @@ -292,6 +292,7 @@ module: bos_video: 3 bos_vocab: 3 bos_web_app: 3 + sanitation_scheduling: 3 menu_link_content: 5 pathauto: 5 content_translation: 10 diff --git a/config/default/field.field.node.article.field_components.yml b/config/default/field.field.node.article.field_components.yml index 1edff48484..b990afe7ff 100644 --- a/config/default/field.field.node.article.field_components.yml +++ b/config/default/field.field.node.article.field_components.yml @@ -39,6 +39,7 @@ dependencies: - paragraphs.paragraphs_type.text - paragraphs.paragraphs_type.transaction_grid - paragraphs.paragraphs_type.video + - paragraphs.paragraphs_type.web_app module: - entity_reference_revisions _core: @@ -91,6 +92,7 @@ settings: group_of_links_quick_links: group_of_links_quick_links news_and_announcements: news_and_announcements events_and_notices: events_and_notices + web_app: web_app negate: 0 target_bundles_drag_drop: 3_column_w_image: @@ -314,5 +316,5 @@ settings: enabled: true web_app: weight: 134 - enabled: false + enabled: true field_type: entity_reference_revisions From be28681c669db8ed461bc3d54b5f32060cf67f05 Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 15:30:35 -0400 Subject: [PATCH 21/48] DIG-4213 fix libraries --- .../sanitation_scheduling.libraries.yml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml index 01c8c5e088..ad4a8e1c46 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml @@ -1,25 +1,21 @@ sanitation_scheduling-dev: version: scheduling.123456 - js: - app/frontend/dist/assets/bundle.js: {preprocess: false, attributes: {type: 'module', id: sanitation-js}} css: - theme: - //fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap: { preprocess: false, type: external }, layout: css/sanitation_scheduling.overrides.css: {preprocess: true, attributes: {media: screen, type: text/css}} app/frontend/dist/assets/index.css: {preprocess: false, attributes: {media: screen, type: text/css}} + js: + app/frontend/dist/assets/bundle.js: {preprocess: false, attributes: {type: 'module', id: sanitation-js}} dependencies: - core/drupalSettings sanitation_scheduling: version: scheduling.123456 - js: - https://sanitation-scheduling-dev.web.app/assets/bundle.js: {preprocess: false, attributes: {type: 'module', id: sanitation-js}} css: - theme: - //fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap: { preprocess: false, type: external }, layout: css/sanitation_scheduling.overrides.css: {preprocess: true, attributes: {media: screen, type: text/css}} - https://sanitation-scheduling-dev.web.app/assets/index.css: {preprocess: false, attributes: {media: screen, type: text/css}} + //sanitation-scheduling-dev.web.app/assets/index.css: {preprocess: false, attributes: {media: screen, type: text/css}} + js: + //sanitation-scheduling-dev.web.app/assets/bundle.js: {preprocess: false, attributes: {type: 'module', id: sanitation-js}} dependencies: - core/drupalSettings From 816f17ba5ea3bc2bcd6bdee6fc3797746f016651 Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 15:32:39 -0400 Subject: [PATCH 22/48] Automates Github Action --- .github/workflows/D10-Deploy.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/D10-Deploy.yml b/.github/workflows/D10-Deploy.yml index b15311dce1..a5e62cb4b6 100644 --- a/.github/workflows/D10-Deploy.yml +++ b/.github/workflows/D10-Deploy.yml @@ -23,15 +23,14 @@ name: "Deploy to Acquia" on: workflow_dispatch: - # push: - # branches: # we can add branches to this list which will deploy code to Acquia GitLab as we push code to those branches. - # - dummy + push: + branches: # we can add branches to this list which will deploy code to Acquia GitLab as we push code to those branches. + - CI_working + - DEV2_working + - UAT_working # - develop # - stage # - production - # - CI_working - # - DEV2_working - # - UAT_working env: ACQUIA_BRANCH: ${{ github.ref_name }}-deploy # the branch name to be used in the Acquia Repo From d2372ec187cacb25228abde8054a0ce71993908f Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 16:37:32 -0400 Subject: [PATCH 23/48] Adds GitHub Actions for deployment. --- .github/config/deploy/deploy-excludes.txt | 58 +++++ .github/config/deploy/deploy-from.txt | 10 + .github/config/slack/slackDeployEnd.yml | 33 +++ .github/config/slack/slackDeployStart.yml | 10 + .github/config/slack/slackPublishEnd.yml | 33 +++ .github/config/slack/slackPublishStart.yml | 10 + .github/workflows/D10-Deploy.yml | 276 +++++++++++++++++++++ 7 files changed, 430 insertions(+) create mode 100644 .github/config/deploy/deploy-excludes.txt create mode 100644 .github/config/deploy/deploy-from.txt create mode 100644 .github/config/slack/slackDeployEnd.yml create mode 100644 .github/config/slack/slackDeployStart.yml create mode 100644 .github/config/slack/slackPublishEnd.yml create mode 100644 .github/config/slack/slackPublishStart.yml diff --git a/.github/config/deploy/deploy-excludes.txt b/.github/config/deploy/deploy-excludes.txt new file mode 100644 index 0000000000..5d65dd8ca9 --- /dev/null +++ b/.github/config/deploy/deploy-excludes.txt @@ -0,0 +1,58 @@ +# IDE generated files +.idea* +.css* +.eslint* +.editorconfig + +# Unwanted files from the repo-root +.lando.yml +build.xml + +# Unwanted files from the docroot +docroot/web.config +docroot/sites/default/files +docroot/example.gitignore + +# Unwanted settings files +docroot/sites/default/settings/settings.terraform.php +docroot/sites/default/settings/settings.travis.php +docroot/sites/default/settings/settings.local.php +docroot/sites/default/settings/private.settings.php +docroot/sites/default/default.services.yml +docroot/sites/default/default.settings.php +docroot/sites/example.settings.local.php +docroot/sites/example.sites.php + +# Acquia hook files that aren't needed +hooks/samples +hooks/templates + +# Drush file +drush/drush.yml + +# Git files +.git +.gitignore +.gitattributes + +# Any package files +package.json +package-lock.json + +# Detailed exclusions for webapps. +# - must add enough of the full path to ensure accurate removals. +node_modules +bos_web_app/apps/**/src +bos_web_app/apps/**/*.html +bos_web_app/apps/**/*.json +bos_web_app/apps/**/postcss*.* +bos_web_app/apps/**/jest*.* +bos_web_app/apps/**/gulp*.* +bos_web_app/apps/**/webpack*.* +bos_web_app/apps/**/babel*.* +bos_web_app/apps/**/favicon.ico +bos_web_app/apps/**/_* +bos_web_app/apps/**/.* + +# COB custom module files which are not required +docroot/modules/custom/**/testing* diff --git a/.github/config/deploy/deploy-from.txt b/.github/config/deploy/deploy-from.txt new file mode 100644 index 0000000000..987032cb6c --- /dev/null +++ b/.github/config/deploy/deploy-from.txt @@ -0,0 +1,10 @@ +config +docroot +drush +hooks +vendor +scripts/deploy/acquia.enc +scripts/deploy/cob_utilities.sh +scripts/composer +composer.lock +composer.json diff --git a/.github/config/slack/slackDeployEnd.yml b/.github/config/slack/slackDeployEnd.yml new file mode 100644 index 0000000000..b5efb93626 --- /dev/null +++ b/.github/config/slack/slackDeployEnd.yml @@ -0,0 +1,33 @@ +username: 'Acquia Environment Deploy' +icon_url: https://boston.gov/digitalteamicon.png + +pretext: Deployment of <{{repositoryUrl}}|{{repositoryName}}> reports {{jobStatus}}. +title: <{{workflowRunUrl}}|Deploy branch {{branch}} {{#ifeq jobStatus "SUCCESS"}}Completed{{else}}INCOMPLETED{{/ifeq}}> +title_link: {{workflowRunUrl}} + +text: | + +fallback: |- + [GitHub] {{payload.repository.name}} finished + +fields: + - title: Job Steps + value: "{{#each jobSteps}}{{icon this.outcome}} {{@key}}\n{{/each}}" + short: false + +footer: >- + {{payload.enterprise.name}}, <{{payload.repository.homepage}}|{{payload.repository.name}}>: _GitHub Action:deploy.yml_#{{runNumber}} + +colors: + info: '#5DADE2' + success: '#1e9042' + failure: '#b80000' + cancelled: '#7D3C98' + default: '#5DADE2' + +icons: + success: ':white_check_mark:' + failure: ':grimacing:' + cancelled: ':x:' + skipped: ':heavy_minus_sign:' + default: ':interrobang:' diff --git a/.github/config/slack/slackDeployStart.yml b/.github/config/slack/slackDeployStart.yml new file mode 100644 index 0000000000..21f52e5120 --- /dev/null +++ b/.github/config/slack/slackDeployStart.yml @@ -0,0 +1,10 @@ +username: 'Acquia Environment Deploy' +icon_url: https://boston.gov/digitalteamicon.png + +pretext: A deployment to Acquia of the branch {{branch}} from the repository <{{repositoryUrl}}|{{repositoryName}}> has been initiated. +title: <{{workflowRunUrl}}|Deploy Start> +title_link: {{workflowRunUrl}} + +text: | + A GitHub Action has been executed because a {{eventName}} event on the branch {{branch}}. This has started a deploy process for this branch. + diff --git a/.github/config/slack/slackPublishEnd.yml b/.github/config/slack/slackPublishEnd.yml new file mode 100644 index 0000000000..d9e7e5bf3f --- /dev/null +++ b/.github/config/slack/slackPublishEnd.yml @@ -0,0 +1,33 @@ +username: 'Drupal {{branch}} Publish' +icon_url: https://boston.gov/digitalteamicon.png + +pretext: Publish of <{{repositoryUrl}}|{{repositoryName}}> reports {{jobStatus}}. +title: <{{workflowRunUrl}}|Publish {{#if jobStatus='Success'}}Completed{{else}}INCOMPLETED{{/if}}> +title_link: {{workflowRunUrl}} + +text: | + +fallback: |- + [GitHub] {{payload.repository.name}} finished + +fields: + - title: Job Steps + value: "{{#each jobSteps}}{{icon this.outcome}} {{@key}}\n{{/each}}" + short: false + +footer: >- + {{payload.enterprise.name}}, <{{payload.repository.homepage}}|{{payload.repository.name}}>: _GitHub Action:publish.yml_#{{runNumber}} + +colors: + info: '#5DADE2' + success: '#0d5c1f' + failure: '#821414' + cancelled: '#7D3C98' + default: '#5DADE2' + +icons: + success: ':white_check_mark:' + failure: ':grimacing:' + cancelled: ':x:' + skipped: ':heavy_minus_sign:' + default: ':interrobang:' diff --git a/.github/config/slack/slackPublishStart.yml b/.github/config/slack/slackPublishStart.yml new file mode 100644 index 0000000000..60475580a0 --- /dev/null +++ b/.github/config/slack/slackPublishStart.yml @@ -0,0 +1,10 @@ +username: 'Drupal {{branch}} Publish' +icon_url: https://boston.gov/digitalteamicon.png + +pretext: A {{branch}} publish of <{{repositoryUrl}}|{{repositoryName}}> has been initiated. +title: <{{workflowRunUrl}}|Publishing Start> +title_link: {{workflowRunUrl}} + +text: | + A {{eventName}} to {{branch}} has started a <{{workflowRunUrl}}|GitHub Action> to sync the public repository and + create release notes in both the <{{repositoryUrl}}|private> and repositories. diff --git a/.github/workflows/D10-Deploy.yml b/.github/workflows/D10-Deploy.yml index 37b0e521e9..194b73244c 100644 --- a/.github/workflows/D10-Deploy.yml +++ b/.github/workflows/D10-Deploy.yml @@ -16,6 +16,7 @@ # -> local: SSH_GITHUB_KEY -> SSH key used to connect to GitHub for remote git operations # -> local: ACQUIA_SSH_KEY -> SSH key used to connect to Acquia GitLab for remote git operations # -> local: ACQUIA_REMOTE_REPO_URL -> URL for the Acquia GitLab repository +# -> local: PRIVATE_REPO_URL -> The private repo to merge into the tracked repo # -> global: SLACK_DOIT_WEBHOOK_URL -> Webhook URL for posting messages to slack # - GitHub VARIABLES: # -> local: SLACK_DRUPAL_CHANNEL -> Channel to post devops messages into @@ -23,3 +24,278 @@ name: "Deploy to Acquia" on: workflow_dispatch: + push: + branches: # we can add branches to this list which will deploy code to Acquia GitLab as we push code to those branches. + - develop + - stage + - CI_working + - DEV2_working + - UAT_working + # - production + +env: + ACQUIA_BRANCH: ${{ github.ref_name }}-deploy # the branch name to be used in the Acquia Repo + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DOIT_WEBHOOK_URL }} # for slack + +jobs: + Deploy: + # installed software: https://github.com/actions/runner-images/blob/main/images/linux/Ubuntu2204-Readme.md + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + + - name: Post to Slack + uses: act10ns/slack@v2.0.0 + with: + status: Starting + channel: ${{ vars.SLACK_MONITORING_CHANNEL }} + + - name: Output some debugging info + if: ${{ vars.DEPLOY_DEBUG == 1 }} + run: | + export + pwd + + # Install some dependencies. + - name: Install additional Linux packages + run: | + sudo add-apt-repository ppa:ondrej/php + sudo apt-get update + sudo apt-get install -y -q libgd3 php-gd php-curl libpng-dev libjpeg-dev libwebp-dev + + # More debugging (full PHP configuration) + - name: Output more debugging info + if: ${{ vars.DEPLOY_DEBUG == 1 }} + run: | + php -i + + # checkout the cob repository that has been pushed to. + - name: Checkout the repository + uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + ssh-key: ${{ secrets.SSH_GITHUB_KEY }} + persist-credentials: false # otherwise, the token used is the GITHUB_TOKEN, instead of your personal token + fetch-depth: 1 # 0 = all + path: candidate # Checkout into this folder + + # checkout the private repository which has settings and secrets etc + - name: Checkout the private repository + uses: actions/checkout@v4 + with: + repository: ${{ secrets.PRIVATE_REPO_URL }} + ssh-key: ${{ secrets.SSH_GITHUB_KEY }} + ref: develop + persist-credentials: false # otherwise, the token used is the GITHUB_TOKEN, instead of your personal token + fetch-depth: 1 # 0 = all + path: private # Checkout into this folder + + # Merge the private repo into the tracked repo + - name: Merge the private repo files + run: | + rm -rf ./private/.git + du ./private + ls -la ./private/docroot/sites/default/settings + find ./private/. -iname '*..gitignore' -exec rename 's/\.\.gitignore/\.gitignore/' '{}' \; + rsync -aE ./private/ ./candidate/ --exclude=*.md + rm -rf ./private + ls -la ./candidate/docroot/sites/default/settings + + # Cache Composer Dependencies + - name: Cache Composer dependencies + uses: actions/cache@v3 + with: + path: /tmp/composer-cache + key: ${{ runner.os }}-${{ hashFiles('**/composer.lock') }} + + # Composer Install: Note: has an SSH option for private repos + - name: Download Drupal and dependencies + id: Build-drupal-using-Composer + uses: php-actions/composer@v6 + env: + USE_DEV: "yes" + with: + dev: ${{ env.USE_DEV }} # download dev packages when used on dev environments + args: --prefer-dist --no-interaction --no-progress --ignore-platform-req=ext-gd --ignore-platform-req=ext-soap + working_dir: candidate + version: 2.5.8 + php_version: 8.3 + + # Composer drupal:scaffold + - name: Install Drupal Scaffold + uses: php-actions/composer@v6 + with: + command: drupal:scaffold # Add drupal scaffolding files + working_dir: candidate + + - name: Directory listings for completed build + if: ${{ vars.DEPLOY_DEBUG == 1 }} + run: | + echo "Directory Tree for $(pwd)/candidate" && du ./candidate + # echo "Directory Listing for $(pwd)/candidate" && ls -lAh --group-directories-first candidate + # echo "Directory Listing for $(pwd)/candidate/docroot" && ls -lAh --group-directories-first candidate/docroot + # echo "Directory Listing for $(pwd)/candidate/docroot/modules/contrib" && ls -lAh --group-directories-first candidate/docroot/modules/contrib + # echo "Directory Listing for $(pwd)/candidate/docroot/sites/default" && ls -lAh --group-directories-first candidate/docroot/sites/default + # echo "Directory Listing for $(pwd)/candidate/docroot/sites/default/settings" && ls -lAh --group-directories-first candidate/docroot/sites/default/settings + + # checkout the acquia repository to push to. + - name: Checkout the Acquia repository + id: Checkout-Acquia-Repo + run: | + acquia_ssh_key_path="${HOME}/.ssh" + acquia_ssh_key_file="${HOME}/.ssh/id_rsa" + [[ ${{ vars.DEPLOY_DEBUG }} == 1 ]] && echo "ssh-key-file: $acquia_ssh_key_file" && echo "ssh-key-path: $acquia_ssh_key_path" + mkdir -p $acquia_ssh_key_path + echo "${{ secrets.ACQUIA_SSH_KEY }}" > "$acquia_ssh_key_file" + chmod 600 $acquia_ssh_key_file + + echo "::notice file=deploy.yml,line=93,title=Success::Drupal codebase was built." + err="" + git config --global --add core.sshCommand "ssh -i $acquia_ssh_key_file" + host=$(echo ${{ secrets.ACQUIA_REMOTE_REPO_URL }} | awk -F'@' '{print $2}' | awk -F':' '{print $1}') || echo "::warning file=deploy.yml,title=Warning::Problem saving known host." + if [[ "$host" != "github.com" ]]; then + echo $(ssh-keyscan -t rsa $host) >> "${HOME}/.ssh/known_hosts" && echo "Host added to ssh known_hosts" || echo "::warning file=deploy.yml,title=Warning::Problem saving known host ($host)." + fi + git config --global user.email "digital-dev@boston.gov" + git config --global user.name ${{ github.triggering_actor }} + git config --global init.defaultBranch ${{ env.ACQUIA_BRANCH }} + + mkdir remote + cd remote + git init && git remote add acquia ${{ secrets.ACQUIA_REMOTE_REPO_URL }} || err="$err: Problem setting remote ref" + git config --local gc.auto 0 || echo "::warning file=deploy.yml,title=Warning::Problem disabling garbage collection (not fatal)." + git -c protocol.version=2 fetch --no-progress --depth=1 --prune --no-recurse-submodules acquia +refs/heads/*:refs/remotes/acquia/* || err="$err: Problem fetching remote branches" + [[ $(git branch --remotes --list acquia/${{ env.ACQUIA_BRANCH }}) == "" ]] && newbranch=1 || newbranch=0 + if [[ $newbranch == 0 ]]; then + git checkout --no-progress --force -B ${{ env.ACQUIA_BRANCH }} refs/remotes/acquia/${{ env.ACQUIA_BRANCH }} || err="$err: Problem checking out ${{ env.ACQUIA_BRANCH }}" + git merge refs/remotes/acquia/${{ env.ACQUIA_BRANCH }} || err="$err: Problem merging ${{ env.ACQUIA_BRANCH }}" + else + git checkout --no-progress --force -B ${{ env.ACQUIA_BRANCH }} || err="$err: Problem creating a new branch" + fi + if [[ ${{ vars.DEPLOY_DEBUG }} == 1 ]]; then + [[ $newbranch == 0 ]] && git log -1 --format='%H' + echo "Directory Listing for $(pwd)" && ls -lAh + fi + cd ../ + + echo "NEW_BRANCH=$newbranch" >> "${GITHUB_ENV}" + + if [[ "$err" != "" ]]; then + echo "::error file=deploy.yml,title=Error,line=120::$err" + exit 1 + fi + + rm -f .git/gc.log + echo "::notice file=deploy.yml,line=120,title=Success::Remote/Acquia repository was checked out." + + # Prepare candidate + - name: Prepare the candidate for pushing to Acquia + id: Prepare-Deploy-Candidate + env: + deploy_from_file: candidate/.github/config/deploy/deploy-from.txt + deploy_excludes_file: candidate/.github/config/deploy/deploy-excludes.txt + run: | + find candidate -type f -regex '\.gitignore$' -delete + find candidate -type f -regex '\.\.gitignore$' -delete + find candidate/docroot/sites/default/ -type f -iregex 'candidate/docroot/.*/default\..*' -delete + find candidate/docroot/sites/default/ -type f -iregex 'candidate/docroot/.*/example\..*' -delete + rsync -rlDWz --delete-after --files-from=${deploy_from_file} --exclude-from=${deploy_excludes_file} candidate remote + printf "docroot/modules/**/.git\ndocroot/libraries/**/.git\n" > remote/.gitignore + cd candidate && mergemsg=$(git log -1 --oneline ${{ github.sha }}) && cd ../ + $(grep -qi 'hotfix' <<< '${mergemsg}') && echo '[NOTICE] HotFix detected' || ([[ ${{ vars.DEPLOY_DEBUG }} == 1 ]] && echo "mergemsg=${mergemsg}") + $(grep -qi 'hotfix' <<< '${mergemsg}') && touch remote/.hotfix || rm -f remote/.hotfix + echo "MERGEMSG=$mergemsg" >> "$GITHUB_ENV" + echo "SHORTSHA=${GITHUB_SHA::8}...${GITHUB_SHA: -4}" >> "$GITHUB_ENV" + + - name: Directory listings for finalized candidate + if: ${{ vars.DEPLOY_DEBUG == 1 }} + run: | + echo "Directory Listing for $(pwd)/remote" && ls -lAh --group-directories-first remote + echo "Directory Listing for $(pwd)/remote/docroot" && ls -lAh --group-directories-first remote/docroot + echo "Directory Listing for $(pwd)/remote/docroot/modules/contrib" && ls -lAh --group-directories-first remote/docroot/modules/contrib + echo "Directory Listing for $(pwd)/remote/docroot/sites/default" && ls -lAh --group-directories-first remote/docroot/sites/default + echo "Directory Listing for $(pwd)/remote/docroot/sites/default/settings" && ls -lAh --group-directories-first remote/docroot/sites/default/settings + + # push to the acquia repository. + - name: PUSH to Acquia + id: Push-Candidate-to-Acquia + env: + commitmsg: "Github PUSH (${{ github.ref_name && github.ref_name || 'develop' }}-${{ env.SHORTSHA }}) by ${{ github.triggering_actor }}: ${{ env.MERGEMSG }}." + run: | + echo "Triggering commit from ${{ github.repository }}:${{ github.ref_name }}" + echo " -> ${{ env.MERGEMSG }}" + host=$(echo ${{ secrets.ACQUIA_REMOTE_REPO_URL }} | awk -F'@' '{print $2}' | awk -F':' '{print $1}') || host="" + echo "Deploy commit into $host:${{ env.ACQUIA_BRANCH }}" + echo " -> ${{ env.commitmsg }}" + + err="" + cd remote + git submodule deinit --all || err="$err: Could not de-initialize submodules" + if [[ ${{ vars.DEPLOY_DEBUG }} == 1 ]]; then + echo "Working Tree Status (pre-add&commit):" + git status + fi + git add --all && echo ' ' || err="$err: Failed to add changed files" + [[ ${{ vars.DEPLOY_DEBUG }} == 1 ]] && commitopt="--status" || commitopt="--quiet" + res=$(git commit -m '${{ env.commitmsg }}' $commitopt) || err="$err: Problem committing changes" + if [[ ${{ vars.DEPLOY_DEBUG }} == 1 ]]; then + echo "Working Tree Status (post-add&commit):" + git status + fi + pushopts="" + if [[ ${{ vars.DEPLOY_DEBUG }} == 1 ]]; then + echo "Commit results:" && echo $res + pushopts="--verbose" + fi + if [[ ${{ vars.DEPLOY_DRY_RUN }} == 1 ]]; then + pushopts="$pushopts --dry-run" + echo "::notice file=deploy.yml,title=DRY-RUN::DRY_RUN envar set. Any commits will not be pushed to Acquia." + fi + if [[ $(echo "$res" | grep "nothing to commit") == "" ]]; then + echo "changes=1" >> "$GITHUB_OUTPUT" + echo "git push --set-upstream acquia ${{ env.ACQUIA_BRANCH }}:${{ env.ACQUIA_BRANCH }} ${pushopts}" + git push --set-upstream acquia ${{ env.ACQUIA_BRANCH }}:${{ env.ACQUIA_BRANCH }} ${pushopts} || err="$err: Problem pushing changes to Acquia" + if [[ ${{ vars.DEPLOY_DRY_RUN }} == 0 ]]; then + echo "::notice file=deploy.yml,title=Success::Remote/Acquia repository was updated- check Acquia for deploy status." + else + echo "::notice file=deploy.yml,title=Success::Remote/Acquia repository was not updated because this was a dry-run." + fi + else + echo "changes=0" >> "$GITHUB_OUTPUT" + echo "::notice file=deploy.yml,title=No Changes::No changes were found to be pushed to Acquia." + fi + rm -rf $aquia_ssh_key_path + if [[ "$err" != "" ]]; then + echo "::error file=deploy.yml,title=Error,line=213::$err" + exit 1 + fi + + - name: Post to Slack - success + uses: act10ns/slack@v2.0.0 + if: ${{ success() && steps.Push-Candidate-to-Acquia.outputs.changes == 1 }} + with: + status: ${{ job.status }} + steps: ${{ toJson(steps) }} + channel: ${{ vars.SLACK_MONITORING_CHANNEL }} + config: candidate/.github/config/slack/slackDeployEnd.yml + + - name: Post to Slack - nothing done + uses: act10ns/slack@v2.0.0 + if: ${{ success() && steps.Push-Candidate-to-Acquia.outputs.changes == 0 }} + with: + status: ${{ job.status }} + steps: ${{ toJson(steps) }} + channel: ${{ vars.SLACK_MONITORING_CHANNEL }} + message: There were no changes to upload to Acquia + + + - name: Post to Slack - failure + uses: act10ns/slack@v2.0.0 + if: ${{ failure() }} + with: + status: ${{ job.status }} + steps: ${{ toJson(steps) }} + channel: ${{ vars.SLACK_MONITORING_CHANNEL }} + message: There were issues with the deploy please check the github action {{workflowRunUrl}} From a989d209d3e441e1340ce458bfee8fe381a8a36a Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 16:49:32 -0400 Subject: [PATCH 24/48] Delete .github/workflows/percy-d10.yml --- .github/workflows/percy-d10.yml | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 .github/workflows/percy-d10.yml diff --git a/.github/workflows/percy-d10.yml b/.github/workflows/percy-d10.yml deleted file mode 100644 index 015b81fad0..0000000000 --- a/.github/workflows/percy-d10.yml +++ /dev/null @@ -1,33 +0,0 @@ -# @file(yaml) -# == GITHUB ACTION == -# Percy screenshot scripting for Boston.gov -# Workflow monitors master branch and is triggered by a Pull Request. -# The action is triggered before the code reaches the stage environment, so the workflow compares screenshots taken -# from the develop environment. -name: D10 visual Regression Testing -on: - pull_request: - branches: [ "drupal10" ] - workflow_dispatch: -jobs: - percy_frontend_test: - runs-on: ubuntu-latest - defaults: - run: - shell: bash - steps: - - name: checkout percy files - uses: Bhacaz/checkout-files@v2 - with: - files: .github/percy - branch: d10-percy - - name: Setup Node 16 - uses: actions/setup-node@v3 - with: - node-version: '16' - - name: Install Percy CLI - run: npm install --save-dev @percy/cli - - name: Compare Frontend Snapshots - run: npx @percy/cli snapshot --config "$GITHUB_WORKSPACE/.github/percy/percy_config.yml" "$GITHUB_WORKSPACE/.github/percy/d10-verification.yml" - env: - PERCY_TOKEN: ${{ secrets.PERCY_TOKEN_STAGE }} From 0b1734eb68e3c1e62a8a9daf29d905905a8a92bb Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 16:50:18 -0400 Subject: [PATCH 25/48] Delete .github/workflows/drupal_build.yml --- .github/workflows/drupal_build.yml | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .github/workflows/drupal_build.yml diff --git a/.github/workflows/drupal_build.yml b/.github/workflows/drupal_build.yml deleted file mode 100644 index 29a0d52d7a..0000000000 --- a/.github/workflows/drupal_build.yml +++ /dev/null @@ -1,21 +0,0 @@ -# @file(yaml) -# == GITHUB ACTION == -# Build artifact workflow for Boston.gov -# Workflow monitors pipeline branchs and is triggered on Pull Requests. -# This action uses composer to compile all the code required for the site, and then runs PHP code validations. -# -# @see https://github.com/actions/runner-images/blob/main/images/linux/Ubuntu2004-Readme.md -# @see https://github.com/actions/runner-images/blob/main/images/linux/Ubuntu1804-Readme.md -# -name: "Pipeline: Build & Test" - -on: - push: - branches: - - nothing -# push: -# branches: -# - develop -# - master - workflow_dispatch: - From 330f3120c913d4a8fe7650f3ca478555d2cff24e Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 16:50:58 -0400 Subject: [PATCH 26/48] Delete .github/workflows/acquia_deploy.yml --- .github/workflows/acquia_deploy.yml | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 .github/workflows/acquia_deploy.yml diff --git a/.github/workflows/acquia_deploy.yml b/.github/workflows/acquia_deploy.yml deleted file mode 100644 index 61e996f4c1..0000000000 --- a/.github/workflows/acquia_deploy.yml +++ /dev/null @@ -1,16 +0,0 @@ -# @file(yaml) -# == GITHUB ACTION == -# Deploy workflow for Boston.gov -# Workflow monitors pipeline branchs and is triggered on Pull Requests and Merges. -# The action is triggered before the code reaches the stage environment, so the workflow compares screenshots taken -# from the develop environment. -name: "Pipeline: Deploy to Acquia" -on: - push: - branches: - - nothing -# push: -# branches: -# - develop -# - master - workflow_dispatch: From 46aa3c9fbdd09e92e0da2a265f182799ee5f0f6e Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 16:52:50 -0400 Subject: [PATCH 27/48] Delete .github/workflows/percy_snapshot_interactive.yml --- .../workflows/percy_snapshot_interactive.yml | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 .github/workflows/percy_snapshot_interactive.yml diff --git a/.github/workflows/percy_snapshot_interactive.yml b/.github/workflows/percy_snapshot_interactive.yml deleted file mode 100644 index 31ea524fe7..0000000000 --- a/.github/workflows/percy_snapshot_interactive.yml +++ /dev/null @@ -1,33 +0,0 @@ -# @file(yaml) -# == GITHUB ACTION == -# Percy interactive screenshot scripting for Boston.gov -# Workflow monitors master branch and is triggered by a Pull Request. -# The action is triggered before the code reaches the stage environment, so the workflow compares screenshots taken -# from the develop environment. -name: Boston.gov (interactive) Percy Snapshot Regression Testing -on: - pull_request: - branches: [ "master" ] - workflow_dispatch: -jobs: - percy_frontend_test: - runs-on: ubuntu-latest - defaults: - run: - shell: bash - steps: - - name: checkout percy files - uses: Bhacaz/checkout-files@v2 - with: - files: .github/percy - branch: "DIG-853" - - name: Setup Node 16 - uses: actions/setup-node@v3 - with: - node-version: '16' - - name: Install Percy CLI - run: npm install --save-dev @percy/cli - - name: Compare Interactive Snapshots - run: npx @percy/cli snapshot --debug --config "$GITHUB_WORKSPACE/.github/percy/percy_config.yml" "$GITHUB_WORKSPACE/.github/percy/snapshot_interactive_config.js" - env: - PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} From e697fe2155ede9923faa55a76e651e43bd19de29 Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 16:53:07 -0400 Subject: [PATCH 28/48] Delete .github/percy/snapshot_interactive_config.js --- .github/percy/snapshot_interactive_config.js | 45 -------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/percy/snapshot_interactive_config.js diff --git a/.github/percy/snapshot_interactive_config.js b/.github/percy/snapshot_interactive_config.js deleted file mode 100644 index c6abeb2299..0000000000 --- a/.github/percy/snapshot_interactive_config.js +++ /dev/null @@ -1,45 +0,0 @@ -// @file snapshot_interactive_config.js -// This script defines the screenshots that will be used to visually compare -// the outputs from the site dynamically. - -module.exports = [ - { - name: 'Login', - url: 'https://d8-dev.boston.gov/user/login', - waitForSelector: '#user-login-form', - execute() { - document.querySelector('#edit-name').value = 'admin'; - document.querySelector('#edit-pass').value = 'admin'; - document.querySelector('#edit-submit').click(); - }, - additionalSnapshots: [ - { - suffix: ' - Pending', - }, - { - suffix: ' - Complete', - waitForSelector: '#system-messages', - } - ], - }, - { - name: 'Create Article', - url: 'https://d8-dev.boston.gov/node/add/article', - waitForTimeout: 5000, - waitForSelector: '#edit-title-0-value', - execute() { - document.querySelector('#edit-title-0-value').value = 'Test Article'; - document.querySelector("#edit-field-intro-text-0-value").value = "Test Intro"; - document.querySelector("#edit-body-0-value").value = "Test Body Text"; - document.querySelector("#edit-moderation-state-0-state").value = "draft"; - document.querySelector('#edit-submit').click(); - }, - scope: 'div#page', - additionalSnapshots: [ - { - suffix: ' - Created', - waitForSelector: '#system-messages', - } - ], - }, -]; From f75fc1580ea6dadb1810ed6c2b734d92550ea9be Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 1 May 2024 16:53:27 -0400 Subject: [PATCH 29/48] Delete .github/percy/d10-verification.yml --- .github/percy/d10-verification.yml | 101 ----------------------------- 1 file changed, 101 deletions(-) delete mode 100644 .github/percy/d10-verification.yml diff --git a/.github/percy/d10-verification.yml b/.github/percy/d10-verification.yml deleted file mode 100644 index 11322a0382..0000000000 --- a/.github/percy/d10-verification.yml +++ /dev/null @@ -1,101 +0,0 @@ -- name: Article with ALL components - url: https://d8-dev2.boston.gov/departments/digital-team/test-page - waitForTimeout: 5000 - execute: | - jQuery('.paragraphs-item-events-and-notices').addClass("hidden"); - -- name: Event (basic) - WEST END COMMUNITY PRESERVATION - url: https://d8-dev2.boston.gov/node/61556 - -- name: Event (with header) - MAYOR ON MAIN TROLLEY TOUR - url: https://d8-dev2.boston.gov/node/45361 - -- name: Listing Page - PAY AND APPLY - url: https://d8-dev2.boston.gov/node/32906 - -- name: Listing Page - PARKS AND PLAYGROUNDS - url: https://d8-dev2.boston.gov/node/32946 - -- name: Place Profile - ANIMAL CARE AND CONTROL CENTER - url: https://d8-dev2.boston.gov/node/2191 - -- name: Place Profile - BAY VILLAGE HISTORIC DISTRICT - url: https://d8-dev2.boston.gov/node/3251 - -- name: Person Profile - MARK CIOMMO - url: https://d8-dev2.boston.gov/node/401 - -- name: Person Profile - KIM JANEY - url: https://d8-dev2.boston.gov/node/38046 - -- name: Program Initiative Page - MY BROTHERS KEEPER BOSTON - url: https://d8-dev2.boston.gov/node/7396 - -- name: Program Initiative Page - AGE-FRIENDLY BOSTON - url: https://d8-dev2.boston.gov/node/25396 - -- name: Post - BIKE SHARE - url: https://d8-dev2.boston.gov/node/1741 - -- name: Post - RODENT AND PEST CONTROL - url: http://d8-dev2.boston.gov/node/63036 - -- name: How To - CPR TRAINING - url: https://d8-dev2.boston.gov/node/3606 - execute: | - jQuery('.dr-tr:last()').click(); -# Expands last drawer on page - -- name: How To - FILE FOR A PROPERTY TAX ABATEMENT - url: https://d8-dev2.boston.gov/node/12806 - execute: | - jQuery('.dr-tr:last()').click(); -# Expands last drawer on page - -- name: Article - PARKING METERS - url: https://d8-dev2.boston.gov/node/551 - -- name: Article - MAYORS OFFICE OF HOUSING - url: https://d8-dev2.boston.gov/node/2726 - -- name: Department - INSPECTIONAL SERVICES - url: https://d8-dev2.boston.gov/node/151 - execute: | - jQuery('.paragraphs-item-events-and-notices').addClass("hidden"); -# Hides dynamic content on page to reduce false positives for changes - -- name: Department - HUMAN RESOURCES - url: https://d8-dev2.boston.gov/node/216 - execute: | - jQuery('.dr-c:first()').; -# Expands first drawer on page - -- name: Public Notice - PUBLIC FACILITIES COMMISSION MEETING - url: https://d8-dev2.boston.gov/node/64966 - -- name: Guide - GETTING AROUND BOSTON - url: https://d8-dev2.boston.gov/node/506 - -- name: Guide - HAVING A CAR IN BOSTON - url: https://d8-dev2.boston.gov/node/6 - -- name: Landing Page - HOMEPAGE - url: https://d8-dev2.boston.gov/node/21 - execute: | - jQuery('.dr-tr:last()').click(); -# Expands first drawer on page - -- name: Landing Page - CAREER CENTER - url: https://d8-dev2.boston.gov/node/19261 - execute: | - jQuery('.paragraphs-item-grid-of-cards:first()').addClass("hidden"); - # Hides dynamic content on page to reduce false positives for changes - -- name: Procurement - INSPECTIONAL SERVICES RELATIVE TO ... - url: https://d8-dev2.boston.gov/node/15920681 - -- name: Procurement - BFD THERMAL IMAGING CAMERAS ... - url: https://d8-dev2.boston.gov/node/15920521 - -- name: Map Verification - 30 Westville St (BH) - url: https://d8-dev2.boston.gov/buildinghousing/30-westville-st From d93a189342e5d3ce0182ef94ef287ed8cc29383b Mon Sep 17 00:00:00 2001 From: David Upton Date: Thu, 2 May 2024 12:42:07 -0400 Subject: [PATCH 30/48] DIG-4213 Style updates --- .../css/sanitation_scheduling.overrides.css | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css index 021f0f87bc..88e27af038 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css @@ -1,8 +1,16 @@ +div.paragraphs-item-web-app { + padding-left: 0; + padding-right:0; +} div#sanitation-scheduling-app { color: #000; + margin:0; + /* max-width: 660px */ + padding: 0; + line-height: 1.2rem; } div#sanitation-scheduling-app h1 { - margin: unset; + /*margin: unset;*/ letter-spacing: unset; line-height: unset; } @@ -30,3 +38,6 @@ div#sanitation-scheduling-app label { line-height: unset; margin: unset; } +div#sanitation-scheduling-app p { + margin: 0 0 4px 0; +} From 38161a838ce264a32ed5e4a4c6e4208021ed6982 Mon Sep 17 00:00:00 2001 From: David Upton Date: Thu, 2 May 2024 17:45:45 -0400 Subject: [PATCH 31/48] DIG-4213 Extends bos_web_app component and re-templates. --- ...form_display.paragraph.web_app.default.yml | 54 +++++++++++++++++- ...view_display.paragraph.web_app.default.yml | 56 ++++++++++++++++++- ...ld.field.node.article.field_components.yml | 4 +- ...aragraph.web_app.field_component_title.yml | 19 +++++++ ...paragraph.web_app.field_hide_title_bar.yml | 23 ++++++++ ...ld.paragraph.web_app.field_short_title.yml | 19 +++++++ ...ld.paragraph.web_app.field_webapp_name.yml | 21 +++++++ ...ld.storage.paragraph.field_webapp_name.yml | 24 ++++++++ .../css/sanitation_scheduling.overrides.css | 15 +++++ .../sanitation_scheduling.info.yml | 4 +- .../sanitation_scheduling.module | 14 +++++ .../modules/bos_web_app/bos_web_app.info.yml | 5 ++ .../modules/bos_web_app/bos_web_app.module | 34 ++++++++++- .../templates/paragraph--web-app.html.twig | 24 +++++++- 14 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 config/default/field.field.paragraph.web_app.field_component_title.yml create mode 100644 config/default/field.field.paragraph.web_app.field_hide_title_bar.yml create mode 100644 config/default/field.field.paragraph.web_app.field_short_title.yml create mode 100644 config/default/field.field.paragraph.web_app.field_webapp_name.yml create mode 100644 config/default/field.storage.paragraph.field_webapp_name.yml diff --git a/config/default/core.entity_form_display.paragraph.web_app.default.yml b/config/default/core.entity_form_display.paragraph.web_app.default.yml index c82f81ad6b..66ea010007 100644 --- a/config/default/core.entity_form_display.paragraph.web_app.default.yml +++ b/config/default/core.entity_form_display.paragraph.web_app.default.yml @@ -4,7 +4,30 @@ status: true dependencies: config: - field.field.paragraph.web_app.field_app_name + - field.field.paragraph.web_app.field_component_title + - field.field.paragraph.web_app.field_hide_title_bar + - field.field.paragraph.web_app.field_short_title + - field.field.paragraph.web_app.field_webapp_name - paragraphs.paragraphs_type.web_app + module: + - field_group +third_party_settings: + field_group: + group_title: + children: + - field_component_title + - field_hide_title_bar + label: title + region: content + parent_name: '' + weight: 0 + format_type: fieldset + format_settings: + classes: g + show_empty_fields: false + id: '' + description: '' + required_fields: true _core: default_config_hash: 0ZBqzWJCbLu72UAADPRLeyvHicZbcKJtllhpwtoI1Zs id: paragraph.web_app.default @@ -14,12 +37,41 @@ mode: default content: field_app_name: type: string_textfield - weight: 0 + weight: 3 region: content settings: size: 60 placeholder: '' third_party_settings: { } + field_component_title: + type: string_textfield + weight: 1 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + field_hide_title_bar: + type: boolean_checkbox + weight: 2 + region: content + settings: + display_label: true + third_party_settings: { } + field_short_title: + type: string_textfield + weight: 1 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + field_webapp_name: + type: options_select + weight: 2 + region: content + settings: { } + third_party_settings: { } hidden: created: true status: true diff --git a/config/default/core.entity_view_display.paragraph.web_app.default.yml b/config/default/core.entity_view_display.paragraph.web_app.default.yml index 1eed82b7fe..727ff71c99 100644 --- a/config/default/core.entity_view_display.paragraph.web_app.default.yml +++ b/config/default/core.entity_view_display.paragraph.web_app.default.yml @@ -4,7 +4,14 @@ status: true dependencies: config: - field.field.paragraph.web_app.field_app_name + - field.field.paragraph.web_app.field_component_title + - field.field.paragraph.web_app.field_hide_title_bar + - field.field.paragraph.web_app.field_short_title + - field.field.paragraph.web_app.field_webapp_name - paragraphs.paragraphs_type.web_app + module: + - fences + - options _core: default_config_hash: q_CYfhfLjZAj3JmFd73cKU9BKb_ePIA0CF5CT_emQoY id: paragraph.web_app.default @@ -14,10 +21,57 @@ mode: default content: field_app_name: type: string - label: above + label: hidden settings: link_to_entity: false third_party_settings: { } weight: 0 region: content + field_component_title: + type: string + label: hidden + settings: + link_to_entity: false + third_party_settings: + fences: + fences_field_tag: none + fences_field_classes: '' + fences_field_item_tag: none + fences_field_item_classes: '' + fences_label_tag: none + fences_label_classes: '' + weight: 0 + region: content + field_hide_title_bar: + type: boolean + label: hidden + settings: + format: default + format_custom_false: '' + format_custom_true: '' + third_party_settings: { } + weight: 6 + region: content + field_short_title: + type: string + label: hidden + settings: + link_to_entity: false + third_party_settings: + fences: + fences_field_tag: none + fences_field_classes: '' + fences_field_item_tag: none + fences_field_item_classes: '' + fences_label_tag: none + fences_label_classes: '' + weight: 1 + region: content + field_webapp_name: + type: list_default + label: above + settings: { } + third_party_settings: { } + weight: 7 + region: content hidden: { } diff --git a/config/default/field.field.node.article.field_components.yml b/config/default/field.field.node.article.field_components.yml index b990afe7ff..1edff48484 100644 --- a/config/default/field.field.node.article.field_components.yml +++ b/config/default/field.field.node.article.field_components.yml @@ -39,7 +39,6 @@ dependencies: - paragraphs.paragraphs_type.text - paragraphs.paragraphs_type.transaction_grid - paragraphs.paragraphs_type.video - - paragraphs.paragraphs_type.web_app module: - entity_reference_revisions _core: @@ -92,7 +91,6 @@ settings: group_of_links_quick_links: group_of_links_quick_links news_and_announcements: news_and_announcements events_and_notices: events_and_notices - web_app: web_app negate: 0 target_bundles_drag_drop: 3_column_w_image: @@ -316,5 +314,5 @@ settings: enabled: true web_app: weight: 134 - enabled: true + enabled: false field_type: entity_reference_revisions diff --git a/config/default/field.field.paragraph.web_app.field_component_title.yml b/config/default/field.field.paragraph.web_app.field_component_title.yml new file mode 100644 index 0000000000..2dece5f239 --- /dev/null +++ b/config/default/field.field.paragraph.web_app.field_component_title.yml @@ -0,0 +1,19 @@ +uuid: 33c49326-7bd9-465e-a954-de28c22181d8 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_component_title + - paragraphs.paragraphs_type.web_app +id: paragraph.web_app.field_component_title +field_name: field_component_title +entity_type: paragraph +bundle: web_app +label: 'Component Title' +description: 'Please provide a title for this component. The title will be displayed across the top of the component underlined by a black bar.' +required: true +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/default/field.field.paragraph.web_app.field_hide_title_bar.yml b/config/default/field.field.paragraph.web_app.field_hide_title_bar.yml new file mode 100644 index 0000000000..cfbe136eea --- /dev/null +++ b/config/default/field.field.paragraph.web_app.field_hide_title_bar.yml @@ -0,0 +1,23 @@ +uuid: 3ae1dcac-2653-4884-bc37-cf48d99cd1bb +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_hide_title_bar + - paragraphs.paragraphs_type.web_app +id: paragraph.web_app.field_hide_title_bar +field_name: field_hide_title_bar +entity_type: paragraph +bundle: web_app +label: 'Hide Title Bar' +description: '' +required: false +translatable: false +default_value: + - + value: 0 +default_value_callback: '' +settings: + on_label: 'On' + off_label: 'Off' +field_type: boolean diff --git a/config/default/field.field.paragraph.web_app.field_short_title.yml b/config/default/field.field.paragraph.web_app.field_short_title.yml new file mode 100644 index 0000000000..45bcf4f04b --- /dev/null +++ b/config/default/field.field.paragraph.web_app.field_short_title.yml @@ -0,0 +1,19 @@ +uuid: 39cc5e58-0cb8-475c-85cd-148115ba029d +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_short_title + - paragraphs.paragraphs_type.web_app +id: paragraph.web_app.field_short_title +field_name: field_short_title +entity_type: paragraph +bundle: web_app +label: 'Navigation Title' +description: 'The nav title is used to populate the in-page navigation. Please keep it short: one to three words is ideal.
Example can be found on boston.gov/winter/.' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/default/field.field.paragraph.web_app.field_webapp_name.yml b/config/default/field.field.paragraph.web_app.field_webapp_name.yml new file mode 100644 index 0000000000..fd12352e9a --- /dev/null +++ b/config/default/field.field.paragraph.web_app.field_webapp_name.yml @@ -0,0 +1,21 @@ +uuid: ae13d41d-e0ac-4914-8828-1f1ba8e4d25c +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_webapp_name + - paragraphs.paragraphs_type.web_app + module: + - options +id: paragraph.web_app.field_webapp_name +field_name: field_webapp_name +entity_type: paragraph +bundle: web_app +label: 'Select Web App to embed' +description: '' +required: true +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: list_string diff --git a/config/default/field.storage.paragraph.field_webapp_name.yml b/config/default/field.storage.paragraph.field_webapp_name.yml new file mode 100644 index 0000000000..966a00058b --- /dev/null +++ b/config/default/field.storage.paragraph.field_webapp_name.yml @@ -0,0 +1,24 @@ +uuid: a3069d04-e096-4ba7-bdcb-744caefce6f7 +langcode: en +status: true +dependencies: + module: + - options + - paragraphs +id: paragraph.field_webapp_name +field_name: field_webapp_name +entity_type: paragraph +type: list_string +settings: + allowed_values: + - + value: void + label: void + allowed_values_function: 'bos_web_app_allowed_webapps' +module: options +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css index 88e27af038..f2960d4e9c 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css @@ -18,6 +18,7 @@ div#sanitation-scheduling-app h2 { text-transform: unset; letter-spacing: unset; line-height: unset; + margin-bottom: 0; } div#sanitation-scheduling-app h3 { text-transform: unset; @@ -41,3 +42,17 @@ div#sanitation-scheduling-app label { div#sanitation-scheduling-app p { margin: 0 0 4px 0; } + + +/* FIXES SPEEDLINE SHOULD MAKE */ +/* overall font size in component */ +div#sanitation-scheduling-app a, +div#sanitation-scheduling-app .tw-md , +div#sanitation-scheduling-app .tw-base { + font-size: 1rem; +} +/* italics instructions font size */ +div#sanitation-scheduling-app .tw-text-italic , +div#sanitation-scheduling-app .tw-sm { + font-size: .85rem; +} diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.info.yml b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.info.yml index 9f1629a76d..152cd5142b 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.info.yml +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.info.yml @@ -1,8 +1,8 @@ -name: 'sanitation_scheduling' +name: 'Sanitation Scheduling' type: module description: 'React app hosted on Boston.gov allowing residents to schedule Mattress and Bulk item collection' core_version_requirement: ^10 -package: 'Custom' +package: 'bos_web_app' dependencies: - bos_web_app config_devel: { } diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module index b538186d6a..6a44514742 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module @@ -7,6 +7,20 @@ @file docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module */ + +/** + * Implements module preprocess_paragraph__web_app() + * + * In this function we embed the css and js required by the webapp component, + * (using a library). + * Optionally we can + * - soecify the id of the anchor element the webapp will be built within. + * - specify a client-side js command to init/launch the webapp. + * + * @param array $vars + * + * @return void + */ function sanitation_scheduling_preprocess_paragraph__web_app(array &$vars) { if ($vars["elements"]["field_app_name"][0]["#context"]["value"] == "sanitation_scheduling") { diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.info.yml b/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.info.yml index 4c60a8904c..09c592412e 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.info.yml +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.info.yml @@ -13,3 +13,8 @@ config_devel: - core.entity_view_display.paragraph.web_app.default - core.entity_form_display.paragraph.web_app.default - field.field.paragraph.web_app.field_app_name + - field.storage.paragraph.field_webapp_name + - field.field.paragraph.web_app.field_webapp_name + - field.field.paragraph.web_app.field_short_title + - field.field.paragraph.web_app.field_hide_title_bar + - field.field.paragraph.web_app.field_component_title diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module b/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module index 090284a097..9670da291e 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module @@ -5,8 +5,8 @@ * The Base module file for bos_mnl module. */ -use Drupal\Component\Utility\Html; use Drupal\Core\Render\Markup; +use Symfony\Component\Yaml\Yaml; /** * Implements hook_theme(). @@ -35,7 +35,7 @@ function bos_web_app_preprocess_paragraph__web_app(array &$vars) { if ($paragraph->hasField('field_app_name')) { $libraryDiscovery = \Drupal::service('library.discovery'); $libraries = $libraryDiscovery->getLibrariesByExtension("bos_web_app"); - $app_name = strtolower($paragraph->get('field_app_name')->value); + $app_name = strtolower($paragraph->get('field_webapp_name')->value ?? $paragraph->get('field_app_name')->value); $app_name = str_replace(' ', '_', $app_name); if (isset($libraries[$app_name])) { $vars['#attached']['library'][] = 'bos_web_app/' . $app_name; @@ -46,6 +46,31 @@ function bos_web_app_preprocess_paragraph__web_app(array &$vars) { } } +/** + * Discover webapps. + * + * @return string[] + */ +function bos_web_app_allowed_webapps() { + + $filtered_modules = []; +// $filtered_modules["_none"] = ["- Select WebApp to embed -"]; + // Get an array of all installed modules. + $module_handler = \Drupal::service('module_handler'); + $modules = $module_handler->getModuleList(); + + foreach ($modules as $module) { + // This will 'discover' modules which are in the bos_web_app module. + if ($module->getName() != "bos_web_app" && str_contains($module->getPath(), "bos_web_app")) { + $info_file = $module->getPath() . '/' . $module->getName(). '.info.yml'; + $info = Yaml::parseFile($info_file); + $filtered_modules[ $module->getName()] = $info['name'] ?: $module->getName(); + } + } + return $filtered_modules; + +} + /** * Implements hook_paragraph_hook_summary_alter(). */ @@ -54,6 +79,9 @@ function bos_web_app_paragraph_web_app_summary_alter(array $form_widget, array $ // Set the summary content. return [ 'attributes' => $attributes, - 'content' => [Markup::create($para["entity"]->get("field_app_name")->value)], + 'content' => [ + Markup::create($para["entity"]->get("field_component_title")->value), + Markup::create($para["entity"]->get("field_webapp_name")[0]->value) + ], ]; } diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig b/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig index 895e386a4c..4fbe52272e 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig @@ -23,7 +23,8 @@ * @see template_preprocess_entity() * @see template_process() #} -{% set class="container content entity entity-paragraphs-item paragraphs-item-text component-section" %} + +{% set class="container content entity entity-paragraphs-item paragraphs-item-text component-section b b--fw" %} {% if not webapp_anchor %} {% set webapp_anchor = {'id': 'web-app'} %} {% endif %} @@ -38,6 +39,27 @@ {% endif %}
+ {% if content.field_component_title and not content.field_hide_title_bar["#items"][0].value %} +
+
+ + {{ title_prefix }} + + {{ content.field_component_title }} + + {{ title_suffix }} + + {% if content.field_contact %} +
+ {{ content.field_contact }} +
+ {% endif %} + +
+
+ {% else %} +
+ {% endif %}
Loading ...
From f61f7ab870d1b2a6244f347846e59570c38f304d Mon Sep 17 00:00:00 2001 From: David Upton Date: Thu, 2 May 2024 18:25:24 -0400 Subject: [PATCH 32/48] DIG-4213 Extends bos_web_app component and re-templates. --- .../bos_components/modules/bos_web_app/bos_web_app.module | 4 ++-- .../bos_web_app/templates/paragraph--web-app.html.twig | 5 ++--- .../bos_content/modules/bos_metrolist/bos_metrolist.module | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module b/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module index 9670da291e..ff5675a034 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module @@ -80,8 +80,8 @@ function bos_web_app_paragraph_web_app_summary_alter(array $form_widget, array $ return [ 'attributes' => $attributes, 'content' => [ - Markup::create($para["entity"]->get("field_component_title")->value), - Markup::create($para["entity"]->get("field_webapp_name")[0]->value) + Markup::create($para["entity"]->get("field_component_title")->value ?? "webapp"), + Markup::create($para["entity"]->get("field_webapp_name")[0]->value ?? "") ], ]; } diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig b/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig index 4fbe52272e..3feaa6c740 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig @@ -24,7 +24,7 @@ * @see template_process() #} -{% set class="container content entity entity-paragraphs-item paragraphs-item-text component-section b b--fw" %} +{% set class="container content entity entity-paragraphs-item paragraphs-item-text component-section" %} {% if not webapp_anchor %} {% set webapp_anchor = {'id': 'web-app'} %} {% endif %} @@ -37,9 +37,8 @@ ); {% endif %} -
- {% if content.field_component_title and not content.field_hide_title_bar["#items"][0].value %} + {% if content.field_component_title and (content.field_hide_title_bar["#items"] and content.field_hide_title_bar["#items"][0].value == 0) %}
diff --git a/docroot/modules/custom/bos_content/modules/bos_metrolist/bos_metrolist.module b/docroot/modules/custom/bos_content/modules/bos_metrolist/bos_metrolist.module index 2c548a98c8..535dbac199 100644 --- a/docroot/modules/custom/bos_content/modules/bos_metrolist/bos_metrolist.module +++ b/docroot/modules/custom/bos_content/modules/bos_metrolist/bos_metrolist.module @@ -283,7 +283,7 @@ function bos_metrolist_set_views_active_rows(array &$rows, array $bounds = [], a $type = strtolower($rowResult->_relationship_entities["field_ml_unit_occupancy_type"]->label()) == $type ? TRUE : FALSE; $amiValue = preg_replace('/\D/', '', $rowResult->_relationship_entities["field_ml_incm_elgblty_ami_thold"]->label()); - $amiBounds = explode('-', $bounds['ami']); + $amiBounds = explode('-', $bounds['ami'] ?? ""); // Sort the ami bounds in case we are given them in the wrong order in the url filters. sort($amiBounds); $amiLow = $amiBounds[0] ?? NULL; @@ -291,7 +291,7 @@ function bos_metrolist_set_views_active_rows(array &$rows, array $bounds = [], a $ami = ($amiValue >= $amiLow && $amiValue <= $amiHigh) ? TRUE : FALSE; - $bedsBounds = explode(' ', $bounds['beds']); + $bedsBounds = explode(' ', $bounds['beds'] ?? ""); $beds = in_array($rowResult->node__field_ml_unit_num_of_bedrooms_field_ml_unit_num_of_bed, $bedsBounds); if (!is_null($bounds['beds']) && !$beds) { From a5b4656bdcee4a939f18be1995a6a4c1a325ceae Mon Sep 17 00:00:00 2001 From: David Upton Date: Thu, 2 May 2024 19:56:29 -0400 Subject: [PATCH 33/48] DIG-4213 restyle and re-template sanitation app. --- .../css/sanitation_scheduling.overrides.css | 5 ++++- .../sanitation_scheduling.module | 3 +++ .../modules/bos_web_app/bos_web_app.module | 5 +++++ .../templates/paragraph--web-app.html.twig | 13 ++++++------- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css index f2960d4e9c..2ad7e61d85 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css @@ -13,6 +13,7 @@ div#sanitation-scheduling-app h1 { /*margin: unset;*/ letter-spacing: unset; line-height: unset; + display: none; } div#sanitation-scheduling-app h2 { text-transform: unset; @@ -42,7 +43,9 @@ div#sanitation-scheduling-app label { div#sanitation-scheduling-app p { margin: 0 0 4px 0; } - +.paragraphs-item-web-app .b-c { + padding-left: 0; +} /* FIXES SPEEDLINE SHOULD MAKE */ /* overall font size in component */ diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module index 6a44514742..d0914949bc 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module @@ -34,6 +34,9 @@ function sanitation_scheduling_preprocess_paragraph__web_app(array &$vars) { } $vars["webapp_anchor"]->setAttribute("id", "sanitation-scheduling-app"); + // Put some padding below the webapp before the footer or next component. + $vars['attributes']->addClass('m-b700'); + // This optional command will cause a script function to run after the page // is loaded. // Any valid javascript added to $vars["autorun"] will be executed. diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module b/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module index ff5675a034..96f2085fc2 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module @@ -28,6 +28,9 @@ function bos_web_app_preprocess_paragraph__web_app(array &$vars) { // Include a library for the bos_web_app module itself. $vars['#attached']['library'][] = 'bos_web_app/bos_web_app'; + // Set some default classes for the webapp wrapper div. + $vars['attributes']->addClass('container content entity entity-paragraphs-item paragraphs-item-text component-section'); + // Legacy, include a library ref for the app name which is defined in the // bos_web_app module. // NOTE: Library inclusion should be handled in the apps own module. @@ -35,6 +38,8 @@ function bos_web_app_preprocess_paragraph__web_app(array &$vars) { if ($paragraph->hasField('field_app_name')) { $libraryDiscovery = \Drupal::service('library.discovery'); $libraries = $libraryDiscovery->getLibrariesByExtension("bos_web_app"); + // TODO: Eventually we can remove field_app_name as embedded apps get + // migrated into field_webapp_name. $app_name = strtolower($paragraph->get('field_webapp_name')->value ?? $paragraph->get('field_app_name')->value); $app_name = str_replace(' ', '_', $app_name); if (isset($libraries[$app_name])) { diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig b/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig index 3feaa6c740..62642ca0d1 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/templates/paragraph--web-app.html.twig @@ -16,15 +16,14 @@ * - paragraphs-item-{bundle} * * Other variables: - * - $classes_array: Array of html class attribute values. It is flattened into - * a string within the variable $classes. + * - $webapp_anchor: array of attributes to place in the web_apps actual anchor. + * - $autorun: a javascript string to be inserted onmto the page, and which will + * fire when the DOMContent is loaded. This is useful to call a + * function needed to initialize the webapp, or to pass/set a variable + * in the webapp. * - * @see template_preprocess() - * @see template_preprocess_entity() - * @see template_process() #} -{% set class="container content entity entity-paragraphs-item paragraphs-item-text component-section" %} {% if not webapp_anchor %} {% set webapp_anchor = {'id': 'web-app'} %} {% endif %} @@ -37,7 +36,7 @@ ); {% endif %} -
+
{% if content.field_component_title and (content.field_hide_title_bar["#items"] and content.field_hide_title_bar["#items"][0].value == 0) %}
From 578278ac49ed716dbb4e5394b4865d8b35079d26 Mon Sep 17 00:00:00 2001 From: David Upton Date: Fri, 3 May 2024 14:42:27 -0400 Subject: [PATCH 34/48] DIG-4213 Final styling of confirmation page. --- .../css/sanitation_scheduling.overrides.css | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css index 2ad7e61d85..47d571928b 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css @@ -12,9 +12,14 @@ div#sanitation-scheduling-app { div#sanitation-scheduling-app h1 { /*margin: unset;*/ letter-spacing: unset; - line-height: unset; + line-height: 1.2em; +} +div#sanitation-scheduling-app h1.tw-xl { display: none; } +div#sanitation-scheduling-app h1.tw-text-xl { + text-transform: none; +} div#sanitation-scheduling-app h2 { text-transform: unset; letter-spacing: unset; @@ -59,3 +64,18 @@ div#sanitation-scheduling-app .tw-text-italic , div#sanitation-scheduling-app .tw-sm { font-size: .85rem; } +div#sanitation-scheduling-app h2.tw-text-sm.tw-text-body-text.tw-my-2 { + padding-bottom: 1rem; +} +div#sanitation-scheduling-app a.tw-mt-4 { + margin-top: 25px; + display: block; +} +div#sanitation-scheduling-app span.tw-md, +div#sanitation-scheduling-app span.tw-base { + margin-bottom: 10px; + display: inline-block; +} +div#sanitation-scheduling-app span.tw-md{ + padding-right: 8px; +} From ea834ff5fc386178fbf5d49c931be951ff326b8f Mon Sep 17 00:00:00 2001 From: David Upton Date: Mon, 6 May 2024 14:37:15 -0400 Subject: [PATCH 35/48] Adds a readme for testing github actions locally. --- .github/workflows/readme.md | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/readme.md diff --git a/.github/workflows/readme.md b/.github/workflows/readme.md new file mode 100644 index 0000000000..dc533f4b7c --- /dev/null +++ b/.github/workflows/readme.md @@ -0,0 +1,38 @@ +# Running Github Actions locally + +1. Find and install ACT, a third party CLI which runs GitHub Actions in a local docker container. +- @try (on linux only) (from root folder) curl https://raw.githubusercontent.com/nektos/act/master/install.sh > install_act.sh +- @see Repo Source https://github.com/nektos/act +- @see binaries (windows, mac, linux) https://github.com/nektos/act/releases +- @see Userguide https://nektosact.com/usage/index.html +2. Ensure you have a functioning Docker installation and that your user account can launch docker containers from the command line. +3. Create a `.env` file with your GitHub Actions variables in it, and a `.secrets` file with your GitHub Actions secrets in it. +3. run +``` +/bin/act -W .github/workflows/test_workflow.yml --var-file '.github/workflows/.env' --secret-file '.github/workflows/.secrets' --action-offline-mode push +``` +- `-W` points to the workflow you require +- `--var-file` points to the file with the variables in it +- `--secret-file` points to the secrets file +- `push` is the event trigger you wish to fire +- and optionally `-action-offline-mode` stops the docker images being pulled each run. +@more run options [here](https://nektosact.com/usage/index.html) +## Knowledgebase +1. **DO NOT COMMIT THE `.env` OR `.secrets` FILE TO THE REPO!**. + + +2. **If steps crash - Check the runner you are using.** + +The first time you run ACT you will be asked to select a **runner**. More information on the GitHub runner used is found [here](https://nektosact.com/usage/runners.html) - typically **medium** is the best choice, but if your Actions fail locally for no apparent reason, and you really have to test the failing step, try the **large** image (**CARE:** the large image is +/-20GB download and +/-60GB on-disk!) before losing all hope! + +[Here](https://github.com/catthehacker/docker_images) is a list of available runners. You can force a different runner by adding this `-P` option to the act command +```yaml +-P =nektos/image_name:tag +``` +where `` is the `runs-on` value defined in the workflow being executed. +(e.g. `act -P ubuntu-18.04=nektos/act-environments-ubuntu:18.04`) + +Alternatively, you can try to find the missing package you require and then install that into the runner manually in one of the first steps. + + + From bf26e0adfc113cd7960d7def03d9278446f05909 Mon Sep 17 00:00:00 2001 From: David Upton Date: Mon, 6 May 2024 23:13:06 -0400 Subject: [PATCH 36/48] DIG-4373 Create beta site for Generative AI summarized roll call votes --- config/default/bos_google_cloud.prompts.yml | 10 +- config/default/bos_google_cloud.settings.yml | 6 +- ...y.node.roll_call_dockets.search_index.yml | 72 ++ ...eld.node.landing_page.field_components.yml | 22 +- .../default/views.view.roll_call_dockets.yml | 631 ++++++++++++++++++ .../node_rollcall/node_rollcall.module | 3 + ...-roll-call-dockets--search-index.html.twig | 95 +++ .../bos_core/src/Commands/BosCoreCommands.php | 68 ++ .../QueueWorker/NodeFieldResummarizer.php | 78 +++ 9 files changed, 976 insertions(+), 9 deletions(-) create mode 100644 config/default/core.entity_view_display.node.roll_call_dockets.search_index.yml create mode 100644 docroot/modules/custom/bos_content/modules/node_rollcall/templates/node--roll-call-dockets--search-index.html.twig create mode 100644 docroot/modules/custom/bos_core/src/Plugin/QueueWorker/NodeFieldResummarizer.php diff --git a/config/default/bos_google_cloud.prompts.yml b/config/default/bos_google_cloud.prompts.yml index 2b12759992..f39bc721ce 100644 --- a/config/default/bos_google_cloud.prompts.yml +++ b/config/default/bos_google_cloud.prompts.yml @@ -1,5 +1,5 @@ -base: '{}' -summarizer: '{}' -rewriter: '{}' -search: '{}' -translation: '{}' +base: '[]' +summarizer: '{"rate":"UHJvdmlkZSBhIHByb2JhYmlsaXR5IHRoYXQgdGhpcyBlbWFpbCB0ZXh0IGlzIHNwYW0u","rollcall":"cHJvdmlkZSBhIHNob3J0IHRleHQgdGl0bGUgZm9yIHRoZSBmb2xsb3dpbmcgdGV4dCB3aGljaCBpcyBhIHZvdGluZyByZWNvcmQgYSBjb3VuY2lsIG1lZXRpbmcuIE9ubHkgdGhlIHRpdGxlIGlzIHJlcXVpcmVkLiBObyB0ZXh0IGZvcm1hdHRpbmcgaXMgcmVxdWlyZWQu""}' +rewriter: '[]' +search: '[]' +translation: '[]' diff --git a/config/default/bos_google_cloud.settings.yml b/config/default/bos_google_cloud.settings.yml index 374a2bc690..923e4bc14e 100644 --- a/config/default/bos_google_cloud.settings.yml +++ b/config/default/bos_google_cloud.settings.yml @@ -9,9 +9,9 @@ auth: client_x509_cert_url: '' summarizer: project_id: vertex-ai-poc-406419 - model_id: gemini-pro - location_id: us-east4 - endpoint: 'https://us-east4-aiplatform.googleapis.com' + model_id: gemini-1.5-pro-preview-0409 + location_id: us-central1 + endpoint: 'https://us-central1-aiplatform.googleapis.com' service_account: service_account_1 cache: '+1 day' search: diff --git a/config/default/core.entity_view_display.node.roll_call_dockets.search_index.yml b/config/default/core.entity_view_display.node.roll_call_dockets.search_index.yml new file mode 100644 index 0000000000..2d32162fc5 --- /dev/null +++ b/config/default/core.entity_view_display.node.roll_call_dockets.search_index.yml @@ -0,0 +1,72 @@ +uuid: 0c664aed-b22e-4265-ab04-38ddfd0397f5 +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.node.search_index + - field.field.node.roll_call_dockets.body + - field.field.node.roll_call_dockets.field_components + - field.field.node.roll_call_dockets.field_meeting_date + - node.type.roll_call_dockets + module: + - datetime + - entity_reference_revisions + - fences + - text + - user +id: node.roll_call_dockets.search_index +targetEntityType: node +bundle: roll_call_dockets +mode: search_index +content: + body: + type: text_default + label: hidden + settings: { } + third_party_settings: + fences: + fences_field_tag: none + fences_field_classes: '' + fences_field_item_tag: none + fences_field_item_classes: '' + fences_label_tag: none + fences_label_classes: '' + weight: 1 + region: content + field_components: + type: entity_reference_revisions_entity_view + label: hidden + settings: + view_mode: default + link: '' + third_party_settings: + fences: + fences_field_tag: none + fences_field_classes: '' + fences_field_item_tag: div + fences_field_item_classes: g + fences_label_tag: none + fences_label_classes: '' + weight: 2 + region: content + field_meeting_date: + type: datetime_default + label: hidden + settings: + timezone_override: '' + format_type: date_format_normal_date + third_party_settings: + fences: + fences_field_tag: div + fences_field_classes: '' + fences_field_item_tag: none + fences_field_item_classes: '' + fences_label_tag: span + fences_label_classes: '' + weight: 0 + region: content +hidden: + content_moderation_control: true + langcode: true + links: true + published_at: true diff --git a/config/default/field.field.node.landing_page.field_components.yml b/config/default/field.field.node.landing_page.field_components.yml index 9ed6e0b514..bb59bb0243 100644 --- a/config/default/field.field.node.landing_page.field_components.yml +++ b/config/default/field.field.node.landing_page.field_components.yml @@ -7,6 +7,7 @@ dependencies: - node.type.landing_page - paragraphs.paragraphs_type.3_column_w_image - paragraphs.paragraphs_type.bos311 + - paragraphs.paragraphs_type.bos_node_search - paragraphs.paragraphs_type.bos_signup_emergency_alerts - paragraphs.paragraphs_type.branded_links - paragraphs.paragraphs_type.cabinet @@ -73,8 +74,8 @@ settings: group_of_links_mini_grid: group_of_links_mini_grid hero_image: hero_image iframe: iframe - map: map list: list + map: map news_and_announcements: news_and_announcements newsletter: newsletter photo: photo @@ -86,6 +87,7 @@ settings: commission_summary: commission_summary branded_links: branded_links commission_search: commission_search + bos_node_search: bos_node_search from_library: from_library group_of_links_quick_links: group_of_links_quick_links events_and_notices: events_and_notices @@ -100,6 +102,9 @@ settings: bos311: weight: -95 enabled: true + bos_node_search: + weight: 78 + enabled: true bos_signup_emergency_alerts: weight: -49 enabled: true @@ -148,6 +153,18 @@ settings: drawers: weight: -89 enabled: true + election_area_results: + weight: 95 + enabled: false + election_candidate_results: + weight: 96 + enabled: false + election_card: + weight: 97 + enabled: false + election_contest_results: + weight: 98 + enabled: false election_results: weight: -60 enabled: false @@ -250,6 +267,9 @@ settings: quote: weight: -54 enabled: false + roll_call_vote: + weight: 133 + enabled: false seamless_doc: weight: -51 enabled: false diff --git a/config/default/views.view.roll_call_dockets.yml b/config/default/views.view.roll_call_dockets.yml index 518bc342c1..a41f95e502 100644 --- a/config/default/views.view.roll_call_dockets.yml +++ b/config/default/views.view.roll_call_dockets.yml @@ -4,6 +4,7 @@ status: true dependencies: config: - core.entity_view_mode.node.roll_call_search + - core.entity_view_mode.node.search_index - field.storage.node.body - field.storage.node.field_meeting_date - field.storage.paragraph.field_councillor @@ -1511,3 +1512,633 @@ display: tags: - 'config:field.storage.node.body' - 'config:field.storage.node.field_meeting_date' + rollcall_search_plain_block: + id: rollcall_search_plain_block + display_title: 'Rollcall Search no summary Block' + display_plugin: block + position: 2 + display_options: + title: '' + fields: + nid: + id: nid + table: node_field_data + field: nid + relationship: none + group_type: group + admin_label: '' + entity_type: node + entity_field: nid + plugin_id: field + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: number_unformatted + settings: { } + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + title: + id: title + table: node_field_data + field: title + relationship: none + group_type: group + admin_label: '' + entity_type: node + entity_field: title + plugin_id: field + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: false + ellipsis: false + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: false + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: string + settings: + link_to_entity: false + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + field_meeting_date: + id: field_meeting_date + table: node__field_meeting_date + field: field_meeting_date + relationship: none + group_type: group + admin_label: '' + plugin_id: field + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: false + empty: unk + hide_empty: false + empty_zero: false + hide_alter_empty: false + click_sort_column: value + type: datetime_plain + settings: + timezone_override: '' + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + body: + id: body + table: node__body + field: body + relationship: none + group_type: group + admin_label: '' + plugin_id: field + label: '' + exclude: false + alter: + alter_text: true + text: "{{ body__value | replace({'\\r\\n':' ', '\\t':' ', '\\n': ' '}) | replace({' ':' '}) | replace({' ':' '}) }}\r\n" + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: true + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: true + trim: false + preserve_tags: '
' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: false + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: false + click_sort_column: value + type: text_default + settings: { } + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + body_1: + id: body_1 + table: node__body + field: body + relationship: none + group_type: group + admin_label: '' + plugin_id: field + label: '' + exclude: false + alter: + alter_text: true + text: '{{ body_1__summary }}' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: summary + type: text_summary_or_trimmed + settings: + trim_length: 600 + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + pager: + type: full + options: + offset: 0 + items_per_page: 10 + total_pages: null + id: 0 + tags: + next: ›› + previous: ‹‹ + first: '« First' + last: 'Last »' + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + quantity: 9 + exposed_form: + type: bef + options: + submit_button: Search + reset_button: true + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: false + sort_asc_label: Asc + sort_desc_label: Desc + text_input_required: 'Select any filter and click on Apply to see results' + text_input_required_format: filtered_html + bef: + general: + autosubmit: false + autosubmit_exclude_textfield: false + autosubmit_textfield_delay: 500 + autosubmit_hide: false + input_required: false + allow_secondary: false + secondary_label: 'Advanced options' + secondary_open: false + reset_button_always_show: false + filter: + body_value: + plugin_id: default + advanced: + placeholder_text: '' + collapsible: false + is_secondary: false + field_meeting_date_value: + plugin_id: bef_datepicker + advanced: + collapsible: false + is_secondary: false + empty: + area: + id: area + table: views + field: area + relationship: none + group_type: group + admin_label: '' + plugin_id: text + empty: true + content: + value: 'Sorry, no matching dockets found.' + format: full_html + tokenize: false + sorts: + field_meeting_date_value: + id: field_meeting_date_value + table: node__field_meeting_date + field: field_meeting_date_value + relationship: none + group_type: group + admin_label: '' + plugin_id: datetime + order: DESC + expose: + label: '' + field_identifier: '' + exposed: false + granularity: day + nid: + id: nid + table: node_field_revision + field: nid + relationship: none + group_type: group + admin_label: '' + entity_type: node + entity_field: nid + plugin_id: standard + order: DESC + expose: + label: '' + field_identifier: '' + exposed: false + filters: + type: + id: type + table: node_field_data + field: type + entity_type: node + entity_field: type + plugin_id: bundle + value: + roll_call_dockets: roll_call_dockets + group: 1 + field_meeting_date_value: + id: field_meeting_date_value + table: node__field_meeting_date + field: field_meeting_date_value + relationship: none + group_type: group + admin_label: '' + plugin_id: datetime + operator: 'not empty' + value: + min: '' + max: '' + value: '' + type: date + group: 1 + exposed: false + expose: + operator_id: field_meeting_date_value_op + label: 'Meeting Date' + description: '' + use_operator: false + operator: field_meeting_date_value_op + operator_limit_selection: false + operator_list: { } + identifier: date + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + administrator: '0' + site_administrator: '0' + developer: '0' + content_editor: '0' + content_reviewer: '0' + content_author: '0' + metrolist_editor: '0' + election_editor: '0' + city_clerk_editor: '0' + min_placeholder: '' + max_placeholder: '' + placeholder: '' + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + body_value: + id: body_value + table: node__body + field: body_value + relationship: none + group_type: group + admin_label: '' + plugin_id: string + operator: allwords + value: '' + group: 2 + exposed: true + expose: + operator_id: body_value_op + label: '' + description: '' + use_operator: false + operator: body_value_op + operator_limit_selection: false + operator_list: { } + identifier: search + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + administrator: '0' + site_administrator: '0' + developer: '0' + content_editor: '0' + content_reviewer: '0' + content_author: '0' + metrolist_editor: '0' + election_editor: '0' + city_clerk_editor: '0' + placeholder: '' + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + filter_groups: + operator: AND + groups: + 1: AND + 2: OR + style: + type: default + options: + grouping: { } + row_class: '' + default_row_class: false + uses_fields: false + row: + type: 'entity:node' + options: + relationship: none + view_mode: search_index + query: + type: views_query + options: + query_comment: '' + disable_sql_rewrite: true + distinct: true + replica: false + query_tags: { } + defaults: + empty: false + query: false + title: false + use_ajax: false + show_admin_links: false + pager: false + exposed_form: false + group_by: false + style: false + row: false + relationships: false + fields: false + sorts: false + filters: false + filter_groups: false + header: false + relationships: + field_components: + id: field_components + table: node__field_components + field: field_components + relationship: none + group_type: group + admin_label: 'field_components: Paragraph' + plugin_id: standard + required: true + taxonomy_term__field_councillor: + id: taxonomy_term__field_councillor + table: paragraph__field_councillor + field: taxonomy_term__field_councillor + relationship: field_components + group_type: group + admin_label: 'field_councillor: Taxonomy term' + plugin_id: standard + required: true + taxonomy_term__field_vote: + id: taxonomy_term__field_vote + table: paragraph__field_vote + field: taxonomy_term__field_vote + relationship: field_components + group_type: group + admin_label: 'field_vote: Taxonomy term' + plugin_id: standard + required: true + use_ajax: true + group_by: true + display_description: 'This block is incorporated in a RollCall_Search paragraph' + show_admin_links: false + header: { } + display_extenders: { } + block_description: 'Rollcall Search' + block_category: Paragraph + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: + - 'config:field.storage.node.body' + - 'config:field.storage.node.field_meeting_date' diff --git a/docroot/modules/custom/bos_content/modules/node_rollcall/node_rollcall.module b/docroot/modules/custom/bos_content/modules/node_rollcall/node_rollcall.module index b5db20c677..46237b77d4 100644 --- a/docroot/modules/custom/bos_content/modules/node_rollcall/node_rollcall.module +++ b/docroot/modules/custom/bos_content/modules/node_rollcall/node_rollcall.module @@ -20,6 +20,9 @@ function node_rollcall_theme($existing, $type, $theme, $path) { 'node__roll_call_dockets__roll_call_search' => [ 'base hook' => 'node', ], + 'node__roll_call_dockets__search_index' => [ + 'base hook' => 'node', + ], 'field__node__title__roll_call_dockets' => [ 'base hook' => 'field', ], diff --git a/docroot/modules/custom/bos_content/modules/node_rollcall/templates/node--roll-call-dockets--search-index.html.twig b/docroot/modules/custom/bos_content/modules/node_rollcall/templates/node--roll-call-dockets--search-index.html.twig new file mode 100644 index 0000000000..0841b6415d --- /dev/null +++ b/docroot/modules/custom/bos_content/modules/node_rollcall/templates/node--roll-call-dockets--search-index.html.twig @@ -0,0 +1,95 @@ +{# +/** + * @file + * Theme override to display a node. + * + * Available variables: + * - node: The node entity with limited access to object properties and methods. + * Only method names starting with "get", "has", or "is" and a few common + * methods such as "id", "label", and "bundle" are available. For example: + * - node.getCreatedTime() will return the node creation timestamp. + * - node.hasField('field_example') returns TRUE if the node bundle includes + * field_example. (This does not indicate the presence of a value in this + * field.) + * - node.isPublished() will return whether the node is published or not. + * Calling other methods, such as node.delete(), will result in an exception. + * See \Drupal\node\Entity\Node for a full list of public properties and + * methods for the node object. + * - label: (optional) The title of the node. + * - content: All node items. Use {{ content }} to print them all, + * or print a subset such as {{ content.field_example }}. Use + * {{ content|without('field_example') }} to temporarily suppress the printing + * of a given child element. + * - author_picture: The node author user entity, rendered using the "compact" + * view mode. + * - metadata: Metadata for this node. + * - date: (optional) Themed creation date field. + * - author_name: (optional) Themed author name field. + * - url: Direct URL of the current node. + * - display_submitted: Whether submission information should be displayed. + * - attributes: HTML attributes for the containing element. + * The attributes.class element may contain one or more of the following + * classes: + * - node: The current template type (also known as a "theming hook"). + * - node--type-[type]: The current node type. For example, if the node is an + * "Article" it would result in "node--type-article". Note that the machine + * name will often be in a short form of the human readable label. + * - node--view-mode-[view_mode]: The View Mode of the node; for example, a + * teaser would result in: "node--view-mode-teaser", and + * full: "node--view-mode-full". + * The following are controlled through the node publishing options. + * - node--promoted: Appears on nodes promoted to the front page. + * - node--sticky: Appears on nodes ordered above other non-sticky nodes in + * teaser listings. + * - node--unpublished: Appears on unpublished nodes visible only to site + * admins. + * - title_attributes: Same as attributes, except applied to the main title + * tag that appears in the template. + * - content_attributes: Same as attributes, except applied to the main + * content tag that appears in the template. + * - author_attributes: Same as attributes, except applied to the author of + * the node tag that appears in the template. + * - title_prefix: Additional output populated by modules, intended to be + * displayed in front of the main title tag that appears in the template. + * - title_suffix: Additional output populated by modules, intended to be + * displayed after the main title tag that appears in the template. + * - view_mode: View mode; for example, "teaser" or "full". + * - teaser: Flag for the teaser state. Will be true if view_mode is 'teaser'. + * - page: Flag for the full page state. Will be true if view_mode is 'full'. + * - readmore: Flag for more state. Will be true if the teaser content of the + * node cannot hold the main body content. + * - logged_in: Flag for authenticated user status. Will be true when the + * current user is a logged-in member. + * - is_admin: Flag for admin user status. Will be true when the current user + * is an administrator. + * + * @see template_preprocess_node() + * + */ +#} +
+ + + + +
+
+
+
Description:
+ {{ content.body }} +
+
+
+
Votes:
+ {{ content.field_components }} +
+
+
+ +
diff --git a/docroot/modules/custom/bos_core/src/Commands/BosCoreCommands.php b/docroot/modules/custom/bos_core/src/Commands/BosCoreCommands.php index f8391d354d..9304093af2 100644 --- a/docroot/modules/custom/bos_core/src/Commands/BosCoreCommands.php +++ b/docroot/modules/custom/bos_core/src/Commands/BosCoreCommands.php @@ -250,4 +250,72 @@ public function clearCacheIconManifest() { ->save(); } + /** + * Gen-AI Body Re-Summarizer: Using AI, resummarize fields of the selected Content Type, + * replacing the ai-generated summary that already exists, ignoring any drupal-side caching. + * This will overwrite any manually set summaries. + * + * @validate-module-enabled bos_core + * @validate-module-enabled bos_google_cloud + * + * @command bos:resummarizer + * @aliases bos:rs + */ + public function reSummarizeFields() { + /* + * 1. Get a list of all nodes which have ai summarized fields + * 2. Get all the nodes and load into a queue + * 3. Create a queue worker which will process the queue and update the summary + */ + $settings = \Drupal::config("bos_core.settings")->get('summarizer'); + $nodesWithAiSummarizedFields = $settings['content_types']??[]; + $count = 0; + $done = 0; + $map = []; + + $opts = "Boston CSS Source Switcher:\n Select server to switch to:\n\n"; + $opts .= " [" . $count++ . "]: Cancel\n"; + foreach ($nodesWithAiSummarizedFields as $ctname => $ctsettings) { + if (!empty($ctsettings['enabled'])) { + foreach($ctsettings['settings']['fields'] as $fieldname => $fieldenabled) { + if ($fieldenabled == 1) { + $map[$count] = [ + "content_type" => $ctname, + "field_name" => $fieldname, + "prompt" => $ctsettings["settings"]["prompt"], + "cache" => $ctsettings["settings"]["cache"], + ]; + $opts .= " [" . $count++ . "]: " . $ctname . "." . $fieldname . "\n"; + } + } + } + } + $ord = $this->io()->ask($opts, NULL); + $resummarize = $map[$ord]; + + $queue = \Drupal::queue('node_field_resummarizer'); + + foreach(\Drupal::service('entity_type.manager') + ->getStorage('node') + ->getQuery() + ->accessCheck(FALSE) + ->condition('status', 1) + ->condition('type', $resummarize["content_type"]) + ->execute() as $nid) { + $queue->createItem([ + "nid" => $nid, // NID to resummarize + "field" => $resummarize["field_name"], // Field to be resummarized + "nocache" => "1", // Ignore existing bos_google_cloud-cached content + "prompt" => $resummarize["prompt"], + "cache" => $resummarize["cache"], + "override_manual" => "1" // (future feature) Overwrite any manually set summary + ]); + $done++; + } + + $res = "Success: Queued $done nodes to be re-summarized by cron."; + $this->output()->writeln($res); + + } + } diff --git a/docroot/modules/custom/bos_core/src/Plugin/QueueWorker/NodeFieldResummarizer.php b/docroot/modules/custom/bos_core/src/Plugin/QueueWorker/NodeFieldResummarizer.php new file mode 100644 index 0000000000..31c5d3108c --- /dev/null +++ b/docroot/modules/custom/bos_core/src/Plugin/QueueWorker/NodeFieldResummarizer.php @@ -0,0 +1,78 @@ +getStorage('node') + ->load($data['nid']); + if (!$node) { + // The node does not exist, nothing we can do here allow the item to + // appear to have been sucessfully processed and remove from queue. + return; + } + try { + $summarizer = Drupal::service("bos_google_cloud.GcTextSummarizer"); + $settings = Drupal::config("bos_core.settings")->get('summarizer'); + $settings = $settings["content_types"][$node->bundle()]["settings"]; + $original_field = $node->get($data['field'])->value; + if ($data["nocache"] == 1) { + $summarizer->invalidateCachedSummary($settings["prompt"], $original_field); + } + $result = $summarizer->execute([ + "text" => $original_field, + "prompt" => $settings["prompt"], + "cache" => [ + "enabled" => TRUE, + "expiry" => $settings["cache"], + ], + ]); + if ($result && !$summarizer->error()) { + $node->{$data["field"]}->summary = $this->sanitize($result); + $node->save(); // Because we have set the summary, it should not be requeried. It is cached anyway. + } + else { + $data["error"] = $summarizer->error() ?: "Summarizer service returned empty result."; + throw new Exception($data["error"]); + } + } + catch (Exception $e) { + // Requeue, but delay retrying for 4 hours. + $data["error"] = $e->getMessage(); + Drupal::logger('bos_core')->error($e->getMessage()); + throw new DelayedRequeueException((60 * 60 * 4), "Node not found"); + } + } + + private function sanitize(string $text): string { + $text = strip_tags($text); + $text = preg_replace("/\s+/", " ", $text); + $text = preg_replace("/\#/", "", $text); + $text = trim($text); + return $text; + } + +} From 078d5091c8154206e4219a1ba178616e5db6c1f2 Mon Sep 17 00:00:00 2001 From: David Upton Date: Mon, 6 May 2024 23:23:04 -0400 Subject: [PATCH 37/48] Delete .travis.yml --- .travis.yml | 164 ---------------------------------------------------- 1 file changed, 164 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d2b3ffe5eb..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,164 +0,0 @@ -os: linux -dist: xenial -group: edge -arch: arm64-graviton2 -language: php - -branches: - # WARNING, scripts cannot track branches with spaces or dashes ("-") in their name. AVOID and use _ instead. - only: - - develop - - master - - CI_working - - DEV2_working - - UAT_working - -php: - - 8.1 - -services: - - mysql - -addons: - apt: - update: true - packages: - - libssl1.0.0 - ssh_known_hosts: - - svn-29892.prod.hosting.acquia.com - - bostond8dev.ssh.prod.acquia-sites.com - -cache: - bundler: true - apt: true - directories: - - "$HOME/.composer/cache" - - "$HOME/.drush/cache" - - "$HOME/.nvm" - - vendor - - node_modules - -notifications: - slack: - - if: branch = master - on_pull_requests: false - on_success: always - on_failure: never - rooms: - # drupal - Only when sucessfully merging to master. - - secure: "No3MBbF9oeNGDRQiy4ebob6Wum0YZUvSOtoeAR5lBwB8L4gQNh7kv3dHBLU80U8i0vQca45YuWBDdeXEMhDNqitUXKTRVj8SEN23PgoqhZhSvSmXBXjMm91ekHedTaFqeYVxvTPFphvYh1/y3l/Wjxf8kp6p6EiBhdP7JsWUePPEkOnVNt3QqGLypVBF4K1XHXn4Yee6dml6du247NLRhrTb+X+rJHntNFewW+5jU/6hQka3psTshtL5q3CUGAAf5pV/j7AmzzYwJ/6A162SonHG5xrE2vhV/YJUnp7Tz5IfRGG87aB8bc55KvAbsIPPxWsxXToH8HH3F+WOCqT4pwg9xh4RHigyTZN5l4kAIdpFO73LoA5BEmJwALcJPYYBsHs67e124ylmBWIeukQQg404783lwocNQrUpBgZHsPbV+t96g88Wyv9th3yEjwPdh3/7DbYkZD76hicmfpqqnmbbGddCcZmDbqbHGQIz0hOLHZrYRexJ0BLxUovoEkMqMRKuNcnwqpEr0iRJeOruzqh0N4xbfnTP1kqHaImS0kXuSRX3eNwnyYBl1NoqLUX8tsyvHPQGb511Rk5Fvk3fsiG0EKe0SHNjuT2V3BaIXiiQdS4FcZruil7JN7wEd+Ry5KcaYAfIUmCBEzBKB9EmIqNpxA9yvZJJy6kSI7Oy/2A=" - template: - - ":bostongov: *NOTICE:* A new deploy from GitHub to the Acquia has been started." - - "This deploy was initiated by %{author}." - - "_Please wait for deploy confirmation message in slack before testing_." - - if: branch = master - on_pull_requests: false - on_success: never - on_failure: always - rooms: - # drupal - Only when failed merging to master. - - secure: "No3MBbF9oeNGDRQiy4ebob6Wum0YZUvSOtoeAR5lBwB8L4gQNh7kv3dHBLU80U8i0vQca45YuWBDdeXEMhDNqitUXKTRVj8SEN23PgoqhZhSvSmXBXjMm91ekHedTaFqeYVxvTPFphvYh1/y3l/Wjxf8kp6p6EiBhdP7JsWUePPEkOnVNt3QqGLypVBF4K1XHXn4Yee6dml6du247NLRhrTb+X+rJHntNFewW+5jU/6hQka3psTshtL5q3CUGAAf5pV/j7AmzzYwJ/6A162SonHG5xrE2vhV/YJUnp7Tz5IfRGG87aB8bc55KvAbsIPPxWsxXToH8HH3F+WOCqT4pwg9xh4RHigyTZN5l4kAIdpFO73LoA5BEmJwALcJPYYBsHs67e124ylmBWIeukQQg404783lwocNQrUpBgZHsPbV+t96g88Wyv9th3yEjwPdh3/7DbYkZD76hicmfpqqnmbbGddCcZmDbqbHGQIz0hOLHZrYRexJ0BLxUovoEkMqMRKuNcnwqpEr0iRJeOruzqh0N4xbfnTP1kqHaImS0kXuSRX3eNwnyYBl1NoqLUX8tsyvHPQGb511Rk5Fvk3fsiG0EKe0SHNjuT2V3BaIXiiQdS4FcZruil7JN7wEd+Ry5KcaYAfIUmCBEzBKB9EmIqNpxA9yvZJJy6kSI7Oy/2A=" - template: - - ":red_circle: *WARNING:* Deploy from GitHub to the Acquia *FAILED* (in Travis)." - - "This unsuccessful deploy attempt was initiated by %{author}." - - "EXPLANATION (from Travis): %{message}" - - if: branch = master OR branch = develop OR branch = CI_working OR branch = DEV2_working OR branch = UAT_working - on_pull_requests: true - on_success: always - on_failure: never - rooms: - # digital_builds - whenever travis runs on PR/Merge actions on develop and master branches. - - secure: "HX7tOsr8pnedT2CWJ053VWQ3gIT8E912Kh4RezdjeZM3Pk67esNQqp8pelvm3FRLGLNTmULaQ/T8RANEvm3AnlxbYClIJ/z/5TH1Mr+elZ1gFbBmpubFQSlt+qUGoILa1vHf0bmNG2L7g6dnVjNRkq+HTPnUffE/WCSpBEfATzbIMQ9xY6Wz5HP1YgQVKII01/VKXwe1/uO9cQaXCegXHaNQUj9mLAKoPqyz5oUxRQ2U2jO74TS9E+yLCout0kzvnnGlRVeEKvM4y3ZGPTgT8VvnjljS4Ftf4Xd8hV8EQAWCLUrlyjtalPiNanZkHZWAq83ATzUYteZZ4P/sjepwv0sEy8mQGMYWybeUfw+423nQnMexAgK+byA553Xn0nFvPxpawkeh8B+nkpCWjSA0wrZY6BKCukdWc9mMb3rYfWgY+yS4aOnF9esEjPMs0llxZQ8H1BYf60Soa7T5jCwSQYbUcviWdPM02OTWJrQAD8uXSXw2EoE6KOHuEJcVrw+FGPMrp3+Czx8MvzyFFddzZ7u/DWjyByvuP4LmkmVF0V2loFG9gwHAGDU+0KcKgMwrtWOzD4rYn0L+mpJCnWaokFubXa3p0v2T5ZQiP9hBnTDeZDA84nxmQATyL6ChGaQDkYTmv2qnIwNTuQJ/YacPiMiWdpCuqKunUP9v1Bm9zF0=" - template: - - ":drupal: DRUPAL *%{branch}* build has %{result}." - - "<%{build_url}|#%{build_number}> of branch _%{branch}_ by %{author} in %{duration}" - - "- %{message}" - - if: branch = master OR branch = develop - on_pull_requests: true - on_success: never - on_failure: always - rooms: - # digital_builds - whenever travis runs on PR/Merge actions on develop and master branches. - - secure: "HX7tOsr8pnedT2CWJ053VWQ3gIT8E912Kh4RezdjeZM3Pk67esNQqp8pelvm3FRLGLNTmULaQ/T8RANEvm3AnlxbYClIJ/z/5TH1Mr+elZ1gFbBmpubFQSlt+qUGoILa1vHf0bmNG2L7g6dnVjNRkq+HTPnUffE/WCSpBEfATzbIMQ9xY6Wz5HP1YgQVKII01/VKXwe1/uO9cQaXCegXHaNQUj9mLAKoPqyz5oUxRQ2U2jO74TS9E+yLCout0kzvnnGlRVeEKvM4y3ZGPTgT8VvnjljS4Ftf4Xd8hV8EQAWCLUrlyjtalPiNanZkHZWAq83ATzUYteZZ4P/sjepwv0sEy8mQGMYWybeUfw+423nQnMexAgK+byA553Xn0nFvPxpawkeh8B+nkpCWjSA0wrZY6BKCukdWc9mMb3rYfWgY+yS4aOnF9esEjPMs0llxZQ8H1BYf60Soa7T5jCwSQYbUcviWdPM02OTWJrQAD8uXSXw2EoE6KOHuEJcVrw+FGPMrp3+Czx8MvzyFFddzZ7u/DWjyByvuP4LmkmVF0V2loFG9gwHAGDU+0KcKgMwrtWOzD4rYn0L+mpJCnWaokFubXa3p0v2T5ZQiP9hBnTDeZDA84nxmQATyL6ChGaQDkYTmv2qnIwNTuQJ/YacPiMiWdpCuqKunUP9v1Bm9zF0=" - template: - - ":red_circle: DRUPAL *%{branch}* build has *%{result}* (in Travis build)." - - "<%{build_url}|#%{build_number}> of branch _%{branch}_ by %{author}" - - "EXPLANATION (from Travis): %{message}" - - if: branch = develop - on_pull_requests: false - on_success: always - on_failure: never - rooms: - # digital_builds - whenever travis runs on PR/Merge actions on develop and master branches. - - secure: "HX7tOsr8pnedT2CWJ053VWQ3gIT8E912Kh4RezdjeZM3Pk67esNQqp8pelvm3FRLGLNTmULaQ/T8RANEvm3AnlxbYClIJ/z/5TH1Mr+elZ1gFbBmpubFQSlt+qUGoILa1vHf0bmNG2L7g6dnVjNRkq+HTPnUffE/WCSpBEfATzbIMQ9xY6Wz5HP1YgQVKII01/VKXwe1/uO9cQaXCegXHaNQUj9mLAKoPqyz5oUxRQ2U2jO74TS9E+yLCout0kzvnnGlRVeEKvM4y3ZGPTgT8VvnjljS4Ftf4Xd8hV8EQAWCLUrlyjtalPiNanZkHZWAq83ATzUYteZZ4P/sjepwv0sEy8mQGMYWybeUfw+423nQnMexAgK+byA553Xn0nFvPxpawkeh8B+nkpCWjSA0wrZY6BKCukdWc9mMb3rYfWgY+yS4aOnF9esEjPMs0llxZQ8H1BYf60Soa7T5jCwSQYbUcviWdPM02OTWJrQAD8uXSXw2EoE6KOHuEJcVrw+FGPMrp3+Czx8MvzyFFddzZ7u/DWjyByvuP4LmkmVF0V2loFG9gwHAGDU+0KcKgMwrtWOzD4rYn0L+mpJCnWaokFubXa3p0v2T5ZQiP9hBnTDeZDA84nxmQATyL6ChGaQDkYTmv2qnIwNTuQJ/YacPiMiWdpCuqKunUP9v1Bm9zF0=" - template: - - "Deployment of <%{build_url}|#%{build_number}> from GitHub to Acquia has been started." - - "_Check the #drupal channel for the deploy completion notice._" - - if: branch = CI_working - on_pull_requests: false - on_success: always - on_failure: never - rooms: - # digital_builds - whenever travis runs on PR/Merge actions on CI_working branch. - - secure: "HX7tOsr8pnedT2CWJ053VWQ3gIT8E912Kh4RezdjeZM3Pk67esNQqp8pelvm3FRLGLNTmULaQ/T8RANEvm3AnlxbYClIJ/z/5TH1Mr+elZ1gFbBmpubFQSlt+qUGoILa1vHf0bmNG2L7g6dnVjNRkq+HTPnUffE/WCSpBEfATzbIMQ9xY6Wz5HP1YgQVKII01/VKXwe1/uO9cQaXCegXHaNQUj9mLAKoPqyz5oUxRQ2U2jO74TS9E+yLCout0kzvnnGlRVeEKvM4y3ZGPTgT8VvnjljS4Ftf4Xd8hV8EQAWCLUrlyjtalPiNanZkHZWAq83ATzUYteZZ4P/sjepwv0sEy8mQGMYWybeUfw+423nQnMexAgK+byA553Xn0nFvPxpawkeh8B+nkpCWjSA0wrZY6BKCukdWc9mMb3rYfWgY+yS4aOnF9esEjPMs0llxZQ8H1BYf60Soa7T5jCwSQYbUcviWdPM02OTWJrQAD8uXSXw2EoE6KOHuEJcVrw+FGPMrp3+Czx8MvzyFFddzZ7u/DWjyByvuP4LmkmVF0V2loFG9gwHAGDU+0KcKgMwrtWOzD4rYn0L+mpJCnWaokFubXa3p0v2T5ZQiP9hBnTDeZDA84nxmQATyL6ChGaQDkYTmv2qnIwNTuQJ/YacPiMiWdpCuqKunUP9v1Bm9zF0=" - template: - - "Deployment of <%{build_url}|#%{build_number}> from GitHub to Acquia has been started." - - "_Check the #drupal channel for the deploy completion notice._" - - if: branch = UAT_working - on_pull_requests: false - on_success: always - on_failure: never - rooms: - # digital_builds - whenever travis runs on PR/Merge actions on CI_working branch. - - secure: "HX7tOsr8pnedT2CWJ053VWQ3gIT8E912Kh4RezdjeZM3Pk67esNQqp8pelvm3FRLGLNTmULaQ/T8RANEvm3AnlxbYClIJ/z/5TH1Mr+elZ1gFbBmpubFQSlt+qUGoILa1vHf0bmNG2L7g6dnVjNRkq+HTPnUffE/WCSpBEfATzbIMQ9xY6Wz5HP1YgQVKII01/VKXwe1/uO9cQaXCegXHaNQUj9mLAKoPqyz5oUxRQ2U2jO74TS9E+yLCout0kzvnnGlRVeEKvM4y3ZGPTgT8VvnjljS4Ftf4Xd8hV8EQAWCLUrlyjtalPiNanZkHZWAq83ATzUYteZZ4P/sjepwv0sEy8mQGMYWybeUfw+423nQnMexAgK+byA553Xn0nFvPxpawkeh8B+nkpCWjSA0wrZY6BKCukdWc9mMb3rYfWgY+yS4aOnF9esEjPMs0llxZQ8H1BYf60Soa7T5jCwSQYbUcviWdPM02OTWJrQAD8uXSXw2EoE6KOHuEJcVrw+FGPMrp3+Czx8MvzyFFddzZ7u/DWjyByvuP4LmkmVF0V2loFG9gwHAGDU+0KcKgMwrtWOzD4rYn0L+mpJCnWaokFubXa3p0v2T5ZQiP9hBnTDeZDA84nxmQATyL6ChGaQDkYTmv2qnIwNTuQJ/YacPiMiWdpCuqKunUP9v1Bm9zF0=" - template: - - "Deployment of <%{build_url}|#%{build_number}> from GitHub to Acquia has been started." - - "_Check the #drupal channel for the deploy completion notice._" - - if: branch = DEV2_working - on_pull_requests: false - on_success: always - on_failure: never - rooms: - # digital_builds - whenever travis runs on PR/Merge actions on CI_working branch. - - secure: "HX7tOsr8pnedT2CWJ053VWQ3gIT8E912Kh4RezdjeZM3Pk67esNQqp8pelvm3FRLGLNTmULaQ/T8RANEvm3AnlxbYClIJ/z/5TH1Mr+elZ1gFbBmpubFQSlt+qUGoILa1vHf0bmNG2L7g6dnVjNRkq+HTPnUffE/WCSpBEfATzbIMQ9xY6Wz5HP1YgQVKII01/VKXwe1/uO9cQaXCegXHaNQUj9mLAKoPqyz5oUxRQ2U2jO74TS9E+yLCout0kzvnnGlRVeEKvM4y3ZGPTgT8VvnjljS4Ftf4Xd8hV8EQAWCLUrlyjtalPiNanZkHZWAq83ATzUYteZZ4P/sjepwv0sEy8mQGMYWybeUfw+423nQnMexAgK+byA553Xn0nFvPxpawkeh8B+nkpCWjSA0wrZY6BKCukdWc9mMb3rYfWgY+yS4aOnF9esEjPMs0llxZQ8H1BYf60Soa7T5jCwSQYbUcviWdPM02OTWJrQAD8uXSXw2EoE6KOHuEJcVrw+FGPMrp3+Czx8MvzyFFddzZ7u/DWjyByvuP4LmkmVF0V2loFG9gwHAGDU+0KcKgMwrtWOzD4rYn0L+mpJCnWaokFubXa3p0v2T5ZQiP9hBnTDeZDA84nxmQATyL6ChGaQDkYTmv2qnIwNTuQJ/YacPiMiWdpCuqKunUP9v1Bm9zF0=" - template: - - "Deployment of <%{build_url}|#%{build_number}> from GitHub to Acquia has been started." - - "_Check the #drupal channel for the deploy completion notice._" - -before_install: - # Add in some required packages (ref: /.lando.yml - - ${TRAVIS_BUILD_DIR}/scripts/deploy/travis_server_customize.sh - -install: - - travis_wait 40 ${TRAVIS_BUILD_DIR}/scripts/deploy/travis_build.sh - -script: - # D7's .travis.yml ran basic validation and Behat/PHPUnit tests in parallel. We don't do this in D8 because it - # causes multiple containers to be created, and if tests pass, multiple (near simultaneous) deployments (to Acquia) - # of monitored branches. - # NOTE: If you do not specify a script, then a PHPUnit command will be executed, and fail. - - ${TRAVIS_BUILD_DIR}/scripts/local/validate.sh "none" "${TRAVIS_EVENT_TYPE}" - # Verifcation is NOT done here but is now done in for PR's only.. - -deploy: - # WARNING, scripts cannot track branches with spaces or dashes ("-") in their name. AVOID and use _ instead. - - provider: script - skip_cleanup: true - script: bash $TRAVIS_BUILD_DIR/scripts/deploy/travis_deploy.sh $TRAVIS_BRANCH - on: - branch: develop - - provider: script - skip_cleanup: true - script: bash $TRAVIS_BUILD_DIR/scripts/deploy/travis_deploy.sh $TRAVIS_BRANCH - on: - branch: master - - provider: script - skip_cleanup: true - script: bash $TRAVIS_BUILD_DIR/scripts/deploy/travis_deploy.sh $TRAVIS_BRANCH - on: - branch: CI_working - - provider: script - skip_cleanup: true - script: bash $TRAVIS_BUILD_DIR/scripts/deploy/travis_deploy.sh $TRAVIS_BRANCH - on: - branch: UAT_working From f3cb3d881ec9bae862ac4a43161cd4e5d326a6b6 Mon Sep 17 00:00:00 2001 From: David Upton Date: Mon, 6 May 2024 23:25:14 -0400 Subject: [PATCH 38/48] . --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 086c038545..156ea96d40 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Build Status](https://travis-ci.org/CityOfBoston/boston.gov-d8.png)](https://travis-ci.org/CityOfBoston/boston.gov-d8) [![Open Source Love](https://badges.frapsoft.com/os/v2/open-source.png?v=103)](https://github.com/ellerbrock/open-source-badges/) -# Boston.gov-d8 +# Boston.gov-d8. [Refer to Wiki](https://github.com/CityOfBoston/boston.gov-d8/wiki) From 06b70177b35b4320b60fcc8b90cbd554e844c361 Mon Sep 17 00:00:00 2001 From: David Upton Date: Tue, 7 May 2024 09:58:59 -0400 Subject: [PATCH 39/48] DIG-4373 Altered prompting. --- config/default/bos_google_cloud.prompts.yml | 2 +- .../custom/bos_core/src/Commands/BosCoreCommands.php | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/config/default/bos_google_cloud.prompts.yml b/config/default/bos_google_cloud.prompts.yml index f39bc721ce..7dca34f615 100644 --- a/config/default/bos_google_cloud.prompts.yml +++ b/config/default/bos_google_cloud.prompts.yml @@ -1,5 +1,5 @@ base: '[]' -summarizer: '{"rate":"UHJvdmlkZSBhIHByb2JhYmlsaXR5IHRoYXQgdGhpcyBlbWFpbCB0ZXh0IGlzIHNwYW0u","rollcall":"cHJvdmlkZSBhIHNob3J0IHRleHQgdGl0bGUgZm9yIHRoZSBmb2xsb3dpbmcgdGV4dCB3aGljaCBpcyBhIHZvdGluZyByZWNvcmQgYSBjb3VuY2lsIG1lZXRpbmcuIE9ubHkgdGhlIHRpdGxlIGlzIHJlcXVpcmVkLiBObyB0ZXh0IGZvcm1hdHRpbmcgaXMgcmVxdWlyZWQu""}' +summarizer: '{"spam":"UHJvdmlkZSBhIHByb2JhYmlsaXR5IHRoYXQgdGhpcyBlbWFpbCB0ZXh0IGlzIHNwYW0u","rollcall":"UHJvdmlkZSBhIHNob3J0IHRleHQgdGl0bGUgZm9yIHRoZSBmb2xsb3dpbmcgdGV4dCwgd2hpY2ggaXMgYSB2b3RpbmcgcmVjb3JkIG9mIGEgQ2l0eSBDb3VuY2lsIG1lZXRpbmcuIE9ubHkgdGhlIHRpdGxlIGlzIHJlcXVpcmVkLiBObyB0ZXh0IGZvcm1hdHRpbmcgaXMgcmVxdWlyZWQu"}' rewriter: '[]' search: '[]' translation: '[]' diff --git a/docroot/modules/custom/bos_core/src/Commands/BosCoreCommands.php b/docroot/modules/custom/bos_core/src/Commands/BosCoreCommands.php index 9304093af2..a7632e8f9e 100644 --- a/docroot/modules/custom/bos_core/src/Commands/BosCoreCommands.php +++ b/docroot/modules/custom/bos_core/src/Commands/BosCoreCommands.php @@ -291,6 +291,12 @@ public function reSummarizeFields() { } } $ord = $this->io()->ask($opts, NULL); + + if ($ord == 0) { + $this->output()->writeln("Cancelled."); + return; + } + $resummarize = $map[$ord]; $queue = \Drupal::queue('node_field_resummarizer'); From 23f97eff88ed2c4731003d0a9c1cdf15395b97c7 Mon Sep 17 00:00:00 2001 From: David Upton Date: Tue, 7 May 2024 15:21:00 -0400 Subject: [PATCH 40/48] Changes stage branch name for github actions --- .github/workflows/D10-Deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/D10-Deploy.yml b/.github/workflows/D10-Deploy.yml index 194b73244c..1ac017d403 100644 --- a/.github/workflows/D10-Deploy.yml +++ b/.github/workflows/D10-Deploy.yml @@ -27,7 +27,7 @@ on: push: branches: # we can add branches to this list which will deploy code to Acquia GitLab as we push code to those branches. - develop - - stage + - master #stage - CI_working - DEV2_working - UAT_working From 2b36137f862f7544ace5b7feab9dfde966ba6063 Mon Sep 17 00:00:00 2001 From: David Upton Date: Tue, 7 May 2024 18:50:24 -0400 Subject: [PATCH 41/48] DIG-4213 Removes temp styling now contained in app. --- .../css/sanitation_scheduling.overrides.css | 77 +------------------ 1 file changed, 4 insertions(+), 73 deletions(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css index 47d571928b..60d7d1851a 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/css/sanitation_scheduling.overrides.css @@ -1,81 +1,12 @@ div.paragraphs-item-web-app { padding-left: 0; - padding-right:0; -} -div#sanitation-scheduling-app { - color: #000; - margin:0; - /* max-width: 660px */ - padding: 0; - line-height: 1.2rem; -} -div#sanitation-scheduling-app h1 { - /*margin: unset;*/ - letter-spacing: unset; - line-height: 1.2em; -} -div#sanitation-scheduling-app h1.tw-xl { - display: none; -} -div#sanitation-scheduling-app h1.tw-text-xl { - text-transform: none; -} -div#sanitation-scheduling-app h2 { - text-transform: unset; - letter-spacing: unset; - line-height: unset; - margin-bottom: 0; -} -div#sanitation-scheduling-app h3 { - text-transform: unset; - font-weight: unset; - font-family: unset; - color: unset; - font-size: unset; - letter-spacing: unset; - line-height: initial; -} -div#sanitation-scheduling-app input { - height: initial; - display: initial; -} -div#sanitation-scheduling-app label { - text-transform: unset; - letter-spacing: unset; - line-height: unset; - margin: unset; -} -div#sanitation-scheduling-app p { - margin: 0 0 4px 0; + padding-right: 0; } + .paragraphs-item-web-app .b-c { padding-left: 0; } -/* FIXES SPEEDLINE SHOULD MAKE */ -/* overall font size in component */ -div#sanitation-scheduling-app a, -div#sanitation-scheduling-app .tw-md , -div#sanitation-scheduling-app .tw-base { - font-size: 1rem; -} -/* italics instructions font size */ -div#sanitation-scheduling-app .tw-text-italic , -div#sanitation-scheduling-app .tw-sm { - font-size: .85rem; -} -div#sanitation-scheduling-app h2.tw-text-sm.tw-text-body-text.tw-my-2 { - padding-bottom: 1rem; -} -div#sanitation-scheduling-app a.tw-mt-4 { - margin-top: 25px; - display: block; -} -div#sanitation-scheduling-app span.tw-md, -div#sanitation-scheduling-app span.tw-base { - margin-bottom: 10px; - display: inline-block; -} -div#sanitation-scheduling-app span.tw-md{ - padding-right: 8px; +div#sanitation-scheduling-app h1.tw-xl { + display: none; } From ec9e7ed86689e5bfd04c57e012f3a6701e2426dd Mon Sep 17 00:00:00 2001 From: David Upton Date: Tue, 7 May 2024 19:18:30 -0400 Subject: [PATCH 42/48] Updates drawer style --- ...tity_view_display.node.roll_call_dockets.search_index.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/default/core.entity_view_display.node.roll_call_dockets.search_index.yml b/config/default/core.entity_view_display.node.roll_call_dockets.search_index.yml index 2d32162fc5..f883c924fa 100644 --- a/config/default/core.entity_view_display.node.roll_call_dockets.search_index.yml +++ b/config/default/core.entity_view_display.node.roll_call_dockets.search_index.yml @@ -37,13 +37,13 @@ content: type: entity_reference_revisions_entity_view label: hidden settings: - view_mode: default + view_mode: roll_call_search link: '' third_party_settings: fences: fences_field_tag: none fences_field_classes: '' - fences_field_item_tag: div + fences_field_item_tag: none fences_field_item_classes: g fences_label_tag: none fences_label_classes: '' From da63e2d3242ea9a0e3f3b5b82c3175cc4b9fa09c Mon Sep 17 00:00:00 2001 From: David Upton Date: Tue, 7 May 2024 20:05:46 -0400 Subject: [PATCH 43/48] fix misnamed config file --- ...e.entity_view_display.node.roll_call_dockets.search_index.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename config/default/{core.entity_view_display.node.roll_call_dockets.search_index.yml => core.entity_view_display.node.roll_call_dockets.search_index.yml} (100%) diff --git a/config/default/core.entity_view_display.node.roll_call_dockets.search_index.yml b/config/default/core.entity_view_display.node.roll_call_dockets.search_index.yml similarity index 100% rename from config/default/core.entity_view_display.node.roll_call_dockets.search_index.yml rename to config/default/core.entity_view_display.node.roll_call_dockets.search_index.yml From 88c94e1f5d927689101f180a8f31caf6a509a6b6 Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 8 May 2024 13:35:21 -0400 Subject: [PATCH 44/48] DIG-4213 Allows prod toggle for CDN switching. --- .../sanitation_scheduling.libraries.yml | 12 ++++++------ .../sanitation_scheduling.module | 7 ++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml index ad4a8e1c46..b895fdc390 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.libraries.yml @@ -2,10 +2,10 @@ sanitation_scheduling-dev: version: scheduling.123456 css: layout: - css/sanitation_scheduling.overrides.css: {preprocess: true, attributes: {media: screen, type: text/css}} - app/frontend/dist/assets/index.css: {preprocess: false, attributes: {media: screen, type: text/css}} + css/sanitation_scheduling.overrides.css: {preprocess: false, attributes: {media: screen, type: 'text/css'}} + //sanitation-scheduling-dev.web.app/assets/index.css: {preprocess: false, attributes: {media: screen, type: 'text/css'}} js: - app/frontend/dist/assets/bundle.js: {preprocess: false, attributes: {type: 'module', id: sanitation-js}} + //sanitation-scheduling-dev.web.app/assets/bundle.js: {preprocess: false, attributes: {type: module, id: sanitation-js}} dependencies: - core/drupalSettings @@ -13,9 +13,9 @@ sanitation_scheduling: version: scheduling.123456 css: layout: - css/sanitation_scheduling.overrides.css: {preprocess: true, attributes: {media: screen, type: text/css}} - //sanitation-scheduling-dev.web.app/assets/index.css: {preprocess: false, attributes: {media: screen, type: text/css}} + css/sanitation_scheduling.overrides.css: {preprocess: true, attributes: {media: screen, type: 'text/css'}} + //sanitation-scheduling-prod.web.app/assets/index.css: {preprocess: false, attributes: {media: screen, type: 'text/css'}} js: - //sanitation-scheduling-dev.web.app/assets/bundle.js: {preprocess: false, attributes: {type: 'module', id: sanitation-js}} + //sanitation-scheduling-prod.web.app/assets/bundle.js: {preprocess: false, attributes: {type: 'module', id: sanitation-js}} dependencies: - core/drupalSettings diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module index d0914949bc..cd1979effb 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module @@ -26,7 +26,12 @@ function sanitation_scheduling_preprocess_paragraph__web_app(array &$vars) { if ($vars["elements"]["field_app_name"][0]["#context"]["value"] == "sanitation_scheduling") { // Attach the sanitation scheduling library. - $vars['#attached']['library'][] = 'sanitation_scheduling/sanitation_scheduling'; + if (getenv('AH_SITE_ENVIRONMENT') == "prod") { + $vars['#attached']['library'][] = 'sanitation_scheduling/sanitation_scheduling'; + } + else { + $vars['#attached']['library'][] = 'sanitation_scheduling/sanitation_scheduling-dev'; + } // Reset the JS app anchor id so that it can correctly embed itself. if (!isset($vars["webapp_anchor"])) { From 38ea9364a8719c7eaa1e5597c08aaaced7aedd18 Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 8 May 2024 17:36:35 -0400 Subject: [PATCH 45/48] DIG-4393 Translation fix for Mattress App Embed --- .../bos_translate/bos_translate.libraries.yml | 2 +- .../modules/bos_translate/js/translate.js | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docroot/modules/custom/bos_components/modules/bos_translate/bos_translate.libraries.yml b/docroot/modules/custom/bos_components/modules/bos_translate/bos_translate.libraries.yml index 9572c9e23b..d7462905a4 100644 --- a/docroot/modules/custom/bos_components/modules/bos_translate/bos_translate.libraries.yml +++ b/docroot/modules/custom/bos_components/modules/bos_translate/bos_translate.libraries.yml @@ -1,5 +1,5 @@ bos_translate_js: - version: 1.x + version: 2.x js: js/translate.js: {} dependencies: diff --git a/docroot/modules/custom/bos_components/modules/bos_translate/js/translate.js b/docroot/modules/custom/bos_components/modules/bos_translate/js/translate.js index cf56aac6d0..77fdd0bad0 100644 --- a/docroot/modules/custom/bos_components/modules/bos_translate/js/translate.js +++ b/docroot/modules/custom/bos_components/modules/bos_translate/js/translate.js @@ -309,41 +309,41 @@ let translate = function(d){ let k=l.substring(1, (l.length-1)); l=k } - let h="//translate.google.com/translate?hl=en&sl=en&u="+l+"&tl="; + let h="//translate.google.com/translate?hl=en&sl=en&tl="; let j=""; let i; j+='
'; let g=[["af", "Afrikaans"], ["sq", "shqip"], ["am", "አማርኛ"], ["ar", "العربية"], ["hy", "հայերեն"], ["az", "آذربایجان دیل"], ["eu", "Euskara"], ["be", "Беларуская мова"], ["bn", "বাংলা"], ["bs", "بۉسانسقى"], ["bg", "български"], ["ca", "català"], ["ceb", "Binisaya"], ["ny", "Chicheŵa"], ["zh-CN", "广东话"], ["zh-TW", "廣東話"], ["co", "Corsu"], ["hr", "Hrvatski"], ["cs", "čeština"], ["da", "dansk"], ["nl", "Nederlands"], ["eo", "Esperanto"], ["et", "eesti keel"], ["tl", "Pilipino"], ["fi", "suomi"], ["fr", "français"], ["fy", "Ōstfräisk"], ["gl", "galego"], ["ka", "ქართული ენა"], ["de", "Deutsch"], ["el", "Ελληνικά"], ["gu", "ગુજરાતી"], ["ht", "Kreyòl ayisyen"], ["ha", "هَرْشَن هَوْسَ"], ["haw", "ʻŌlelo Hawaiʻi"], ["iw", "עִברִית"], ["hi", "हिंदी"], ["hmn", "Lus Hmoob"], ["hu", "Magyar"], ["is", "íslenska"], ["ig", "Ásụ̀sụ̀ Ìgbò"], ["id", "bahasa Indonesia"], ["ga", "Gaeilge"], ["it", "Italiano"], ["ja", "日本語"], ["jw", "باسا جاوا"], ["kn", "ಕನ್ನಡ"], ["kk", "Қазақ тілі"], ["km", "ភាសាខ្មែរ"], ["ko", "한국인"], ["ku", "کورمانجی"], ["ky", "Кыргыз тили"], ["lo", "ລາວ"], ["la", "Lingua Latina"], ["lv", "latviešu valoda"], ["lt", "lietuvių kalba"], ["lb", "Lëtzebuergesch"], ["mk", "македонски"], ["mg", "malagasy"], ["ms", "بهاس ملايو"], ["ml", "മലയാളം"], ["mt", "Malti"], ["mi", "Māori"], ["mr", "मराठी"], ["mn", "монгол"], ["my", "မြန်မာစကား"], ["ne", "नेपाली"], ["no", "norsk"], ["ps", "پښتو"], ["fa", "فارسی"], ["pl", "Polskie"], ["pt", "Português"], ["pa", "ਪੰਜਾਬੀ"], ["ro", "limba română"], ["ru", "Русский"], ["sm", "Gagana fa‘a Sāmoa"], ["gd", "Gàidhlig"], ["sr", "Српски"], ["st", "Sotho"], ["sn", "chiShona"], ["sd", "سنڌي"], ["si", "සිංහල"], ["sk", "slovenčina"], ["sl", "slovenščina"], ["so", "Soomaali"], ["es", "Español"], ["su", "Basa Sunda"], ["sw", "کِسْوَهِيلِ"], ["sv", "svenska"], ["tg", "тоҷикӣ"], ["ta", "தமிழ்"], ["te", "తెలుగు"], ["th", "ไทย"], ["'tr'", "Türkçe"], ["uk", "українська мова"], ["ur", "اردو"], ["uz", "أۇزبېك ﺗﻴﻠی"], ["vi", "Tiếng Việt"], ["cy", "Cymraeg"], ["xh", "isiXhosa"], ["yi", "יידיש"], ["yo", "Èdè Yorùbá"], ["zu", "isiZulu"]]; j+='
"; j+='
"; j+='
"; j+="
"; From 1fedba5128e787afb95bdd081ca0c47c24ed4a94 Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 8 May 2024 17:49:48 -0400 Subject: [PATCH 46/48] DIG-4213 Enables bos_web_apps component on Articles --- config/default/field.field.node.article.field_components.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/default/field.field.node.article.field_components.yml b/config/default/field.field.node.article.field_components.yml index 1edff48484..2a3401ad97 100644 --- a/config/default/field.field.node.article.field_components.yml +++ b/config/default/field.field.node.article.field_components.yml @@ -91,6 +91,7 @@ settings: group_of_links_quick_links: group_of_links_quick_links news_and_announcements: news_and_announcements events_and_notices: events_and_notices + web_app: web_app negate: 0 target_bundles_drag_drop: 3_column_w_image: @@ -314,5 +315,5 @@ settings: enabled: true web_app: weight: 134 - enabled: false + enabled: true field_type: entity_reference_revisions From cabd89b8fc500e9149fe6e0c2cbf79cc255904f5 Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 8 May 2024 19:57:11 -0400 Subject: [PATCH 47/48] DIG-4213 remove requirement on webapp fields. --- .../default/field.field.paragraph.web_app.field_app_name.yml | 2 +- .../field.field.paragraph.web_app.field_webapp_name.yml | 2 +- .../apps/sanitation_scheduling/sanitation_scheduling.module | 3 ++- .../bos_components/modules/bos_web_app/bos_web_app.module | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/config/default/field.field.paragraph.web_app.field_app_name.yml b/config/default/field.field.paragraph.web_app.field_app_name.yml index c7f65aeeb3..4beccc57d8 100644 --- a/config/default/field.field.paragraph.web_app.field_app_name.yml +++ b/config/default/field.field.paragraph.web_app.field_app_name.yml @@ -13,7 +13,7 @@ entity_type: paragraph bundle: web_app label: 'App Name' description: '' -required: true +required: false translatable: false default_value: { } default_value_callback: '' diff --git a/config/default/field.field.paragraph.web_app.field_webapp_name.yml b/config/default/field.field.paragraph.web_app.field_webapp_name.yml index fd12352e9a..a960a347c1 100644 --- a/config/default/field.field.paragraph.web_app.field_webapp_name.yml +++ b/config/default/field.field.paragraph.web_app.field_webapp_name.yml @@ -13,7 +13,7 @@ entity_type: paragraph bundle: web_app label: 'Select Web App to embed' description: '' -required: true +required: false translatable: false default_value: { } default_value_callback: '' diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module index cd1979effb..fa2f3d61bc 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/apps/sanitation_scheduling/sanitation_scheduling.module @@ -23,7 +23,8 @@ */ function sanitation_scheduling_preprocess_paragraph__web_app(array &$vars) { - if ($vars["elements"]["field_app_name"][0]["#context"]["value"] == "sanitation_scheduling") { + if ($vars["paragraph"]->hasField('field_webapp_name') + && $vars["paragraph"]->get('field_webapp_name')->value == "sanitation_scheduling") { // Attach the sanitation scheduling library. if (getenv('AH_SITE_ENVIRONMENT') == "prod") { diff --git a/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module b/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module index 96f2085fc2..e6490c1f0d 100644 --- a/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module +++ b/docroot/modules/custom/bos_components/modules/bos_web_app/bos_web_app.module @@ -35,12 +35,12 @@ function bos_web_app_preprocess_paragraph__web_app(array &$vars) { // bos_web_app module. // NOTE: Library inclusion should be handled in the apps own module. $paragraph = $vars['paragraph']; - if ($paragraph->hasField('field_app_name')) { + if ($paragraph->hasField('field_app_name') && $paragraph->get('field_app_name')->value) { $libraryDiscovery = \Drupal::service('library.discovery'); $libraries = $libraryDiscovery->getLibrariesByExtension("bos_web_app"); // TODO: Eventually we can remove field_app_name as embedded apps get // migrated into field_webapp_name. - $app_name = strtolower($paragraph->get('field_webapp_name')->value ?? $paragraph->get('field_app_name')->value); + $app_name = strtolower($paragraph->get('field_app_name')->value); $app_name = str_replace(' ', '_', $app_name); if (isset($libraries[$app_name])) { $vars['#attached']['library'][] = 'bos_web_app/' . $app_name; From 68e412b58ead4d3ebd2274ae02b7557e145d8b6d Mon Sep 17 00:00:00 2001 From: David Upton Date: Wed, 8 May 2024 23:17:13 -0400 Subject: [PATCH 48/48] DIG-4373 download AI experiments --- config/default/bos_google_cloud.prompts.yml | 2 +- .../node_rollcall/node_rollcall.module | 10 +- .../node_rollcall/node_rollcall.routing.yml | 8 ++ .../src/Controller/Downloader.php | 105 ++++++++++++++++++ .../Controller/node_rollcall_endpoints.http | 8 ++ 5 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 docroot/modules/custom/bos_content/modules/node_rollcall/src/Controller/Downloader.php diff --git a/config/default/bos_google_cloud.prompts.yml b/config/default/bos_google_cloud.prompts.yml index 7dca34f615..8c2eb82580 100644 --- a/config/default/bos_google_cloud.prompts.yml +++ b/config/default/bos_google_cloud.prompts.yml @@ -1,5 +1,5 @@ base: '[]' -summarizer: '{"spam":"UHJvdmlkZSBhIHByb2JhYmlsaXR5IHRoYXQgdGhpcyBlbWFpbCB0ZXh0IGlzIHNwYW0u","rollcall":"UHJvdmlkZSBhIHNob3J0IHRleHQgdGl0bGUgZm9yIHRoZSBmb2xsb3dpbmcgdGV4dCwgd2hpY2ggaXMgYSB2b3RpbmcgcmVjb3JkIG9mIGEgQ2l0eSBDb3VuY2lsIG1lZXRpbmcuIE9ubHkgdGhlIHRpdGxlIGlzIHJlcXVpcmVkLiBObyB0ZXh0IGZvcm1hdHRpbmcgaXMgcmVxdWlyZWQu"}' +summarizer: '{"spam":"UHJvdmlkZSBhIHByb2JhYmlsaXR5IHRoYXQgdGhpcyBlbWFpbCB0ZXh0IGlzIHNwYW0u","rollcall":"UHJvdmlkZSBhIHNob3J0IHRleHQgdGl0bGUgZm9yIHRoZSBmb2xsb3dpbmcgdGV4dCwgd2hpY2ggaXMgYSB2b3RpbmcgcmVjb3JkIG9mIGEgQ2l0eSBDb3VuY2lsIG1lZXRpbmcuIE9ubHkgdGhlIHRpdGxlIGlzIHJlcXVpcmVkLiBEbyBub3QgaW5jbHVkZSBtYXJrZG93biBmb3JtYXR0aW5nLg=="}' rewriter: '[]' search: '[]' translation: '[]' diff --git a/docroot/modules/custom/bos_content/modules/node_rollcall/node_rollcall.module b/docroot/modules/custom/bos_content/modules/node_rollcall/node_rollcall.module index 46237b77d4..9ab383114d 100644 --- a/docroot/modules/custom/bos_content/modules/node_rollcall/node_rollcall.module +++ b/docroot/modules/custom/bos_content/modules/node_rollcall/node_rollcall.module @@ -7,7 +7,7 @@ use Drupal\Core\Render\Markup; use Drupal\taxonomy\Entity\Term; use Drupal\views\Plugin\views\query\QueryPluginBase; use Drupal\views\ViewExecutable; -use Drupal\Core\Url; +use Drupal\Core\Cache\Cache; /** * Implements hook_theme(). @@ -262,6 +262,14 @@ function node_rollcall_entity_create(EntityInterface $entity) { } +/** + * Implements hook_entity_update(). + */ +function node_rollcall_entity_update(EntityInterface $entity) { + // Invalidate the cache for the AI experiment download files. + Cache::invalidateTags(['rollcall_ai_experiment.csv', 'rollcall_ai_experiment.json']); +} + /** * Implements hook_form_alter(). */ diff --git a/docroot/modules/custom/bos_content/modules/node_rollcall/node_rollcall.routing.yml b/docroot/modules/custom/bos_content/modules/node_rollcall/node_rollcall.routing.yml index 18084b61dd..4ab6fdc3af 100644 --- a/docroot/modules/custom/bos_content/modules/node_rollcall/node_rollcall.routing.yml +++ b/docroot/modules/custom/bos_content/modules/node_rollcall/node_rollcall.routing.yml @@ -5,3 +5,11 @@ node_rollcall.uploader: methods: [POST] requirements: _access: 'TRUE' + +node_rollcall.summarizer_api: + path: '/rest/rollcall/experiment/{type}' + defaults: + _controller: '\Drupal\node_rollcall\Controller\Downloader::experiment' + methods: [GET] + requirements: + _access: 'TRUE' diff --git a/docroot/modules/custom/bos_content/modules/node_rollcall/src/Controller/Downloader.php b/docroot/modules/custom/bos_content/modules/node_rollcall/src/Controller/Downloader.php new file mode 100644 index 0000000000..772888df73 --- /dev/null +++ b/docroot/modules/custom/bos_content/modules/node_rollcall/src/Controller/Downloader.php @@ -0,0 +1,105 @@ +accessCheck(FALSE) + ->condition('status', 1) + ->condition('type', "roll_call_dockets"); + $nids = $query->execute(); + + // Chunk the array into groups of X elements and process + $chunks = array_chunk($nids, 100); + + $output = []; + if ($type == "csv") { + $output[] = 'COB ID", "Docket Number", "Original Docket Text", "AI Generated Title"'; + } + + // Loop through the chunks and process each. + foreach ($chunks as $chunk) { + + // Load a batch of nodes. + $nodes = \Drupal::entityTypeManager() + ->getStorage('node') + ->loadMultiple($chunk); + + // Loop through the nodes and process each. + foreach ($nodes as $node) { + $body = $node->get('body')->getValue(); + if ($type == "json") { + $output[] = [ + 'COB ID' => $node->id(), + 'Docket Number' => $node->getTitle(), + 'Original Docket Text' => $this->sanitize($body[0]["value"], $type), + 'AI Generated Title' => $this->sanitize($body[0]["summary"], $type), + ]; + } + elseif ($type == "csv") { + $output[] = "{$node->id()}, {$node->getTitle()}, \"{$this->sanitize($body[0]["value"], $type)}\", \"{$this->sanitize($body[0]["summary"], $type)}\""; + } + } + + $nodes = NULL; + + } + + // Load the correct response for the type requested. + if ($type == "json") { + $response = new CacheableJsonResponse($output, 200); + $response->headers->set('Content-Type', 'application/json'); + } + elseif ($type == "csv") { + $response = new CacheableResponse(implode("\n", $output), 200); + $response->headers->set('Content-Type', 'text/csv'); + } + + // Make the output an attachment/file + $response->headers->set('Content-Disposition', + $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + basename("rollcall_ai_experiment.{$type}") + ) + ); + + // Cache response for this custom URL route for 1 year. + // This cache gets invalidated when a new record is saved (in + // node_rollcall_entity_update() and/or node_rollcall_entity_create() + // in node_rollcall.module. + $cache_metadata = new CacheableMetadata(); + $cache_metadata->setCacheContexts(['url']); + $cache_metadata->setCacheMaxAge(60 * 60 * 24 * 365); // 1 year + $cache_metadata->setCacheTags(["rollcall_ai_experiment.{$type}"]); + $response->addCacheableDependency($cache_metadata); + + return $response; + + } + + private function sanitize(string $text, string $type): string { + $text = strip_tags($text); + $text = str_replace(["\r\n", "\n", "\r"], " ", $text); + $text = preg_replace('/\s+/', ' ', $text); + $text = str_replace(["#", "*"], "", $text); + $text = htmlentities($text, ENT_QUOTES); + $type == "csv" && $text = str_replace([","], [","], $text); + return trim($text); + } + + +} diff --git a/docroot/modules/custom/bos_content/modules/node_rollcall/src/Controller/node_rollcall_endpoints.http b/docroot/modules/custom/bos_content/modules/node_rollcall/src/Controller/node_rollcall_endpoints.http index d3fbadb47c..4b73ab8136 100644 --- a/docroot/modules/custom/bos_content/modules/node_rollcall/src/Controller/node_rollcall_endpoints.http +++ b/docroot/modules/custom/bos_content/modules/node_rollcall/src/Controller/node_rollcall_endpoints.http @@ -157,3 +157,11 @@ Authorization: Bearer 8e4fcc89-0546-404a-bcf9-52695683d960 "start": 13998, "records": 2000 } + +### Export Experiment file +GET {{host}}/rest/rollcall/experiment/json +Content-Type: application/json + +### Export Experiment file csv +GET {{host}}/rest/rollcall/experiment/csv +Content-Type: application/json
Email" . json_encode($item) . "
Response" . json_encode($response ?? []) . "
HTTPCode{$http_code}