From bdf67e2837be3eec51cf647cb974c05daad453e5 Mon Sep 17 00:00:00 2001 From: Seamus Lee Date: Sat, 20 Jun 2020 18:06:25 +1000 Subject: [PATCH] [REF] Ship Flexmailer extension with Core --- .gitignore | 3 +- ext/flexmailer/CHANGELOG.md | 9 + ext/flexmailer/LICENSE.txt | 667 ++++++++++++++++++ ext/flexmailer/README.md | 12 + .../docs/develop/CheckSendableEvent.md | 29 + .../docs/develop/ComposeBatchEvent.md | 62 ++ ext/flexmailer/docs/develop/RunEvent.md | 36 + ext/flexmailer/docs/develop/SendBatchEvent.md | 25 + .../docs/develop/WalkBatchesEvent.md | 26 + ext/flexmailer/docs/develop/index.md | 136 ++++ ext/flexmailer/docs/index.md | 13 + ext/flexmailer/docs/install.md | 19 + ext/flexmailer/flexmailer.civix.php | 477 +++++++++++++ ext/flexmailer/flexmailer.php | 162 +++++ ext/flexmailer/info.xml | 34 + ext/flexmailer/mkdocs.yml | 28 + ext/flexmailer/phpunit.xml.dist | 18 + .../settings/flexmailer.setting.php | 28 + ext/flexmailer/src/API/MailingPreview.php | 78 ++ .../ClickTracker/ClickTrackerInterface.php | 31 + .../src/ClickTracker/HtmlClickTracker.php | 96 +++ .../src/ClickTracker/TextClickTracker.php | 47 ++ ext/flexmailer/src/Event/BaseEvent.php | 56 ++ .../src/Event/CheckSendableEvent.php | 87 +++ .../src/Event/ComposeBatchEvent.php | 55 ++ ext/flexmailer/src/Event/RunEvent.php | 48 ++ ext/flexmailer/src/Event/SendBatchEvent.php | 57 ++ ext/flexmailer/src/Event/WalkBatchesEvent.php | 58 ++ ext/flexmailer/src/FlexMailer.php | 226 ++++++ ext/flexmailer/src/FlexMailerTask.php | 167 +++++ ext/flexmailer/src/Listener/Abdicator.php | 100 +++ ext/flexmailer/src/Listener/Attachments.php | 33 + ext/flexmailer/src/Listener/BaseListener.php | 33 + ext/flexmailer/src/Listener/BasicHeaders.php | 69 ++ ext/flexmailer/src/Listener/BounceTracker.php | 44 ++ .../src/Listener/DefaultBatcher.php | 76 ++ .../src/Listener/DefaultComposer.php | 216 ++++++ ext/flexmailer/src/Listener/DefaultSender.php | 202 ++++++ ext/flexmailer/src/Listener/HookAdapter.php | 37 + ext/flexmailer/src/Listener/OpenTracker.php | 48 ++ .../src/Listener/RequiredFields.php | 109 +++ .../src/Listener/RequiredTokens.php | 157 +++++ ext/flexmailer/src/Listener/SimpleFilter.php | 85 +++ ext/flexmailer/src/Listener/TestPrefix.php | 35 + ext/flexmailer/src/Listener/ToHeader.php | 72 ++ ext/flexmailer/src/MailParams.php | 101 +++ ext/flexmailer/src/Services.php | 143 ++++ ext/flexmailer/src/Validator.php | 70 ++ .../ClickTracker/HtmlClickTrackerTest.php | 90 +++ .../ClickTracker/TextClickTrackerTest.php | 93 +++ .../FlexMailer/ConcurrentDeliveryTest.php | 76 ++ .../Civi/FlexMailer/FlexMailerSystemTest.php | 129 ++++ .../FlexMailer/Listener/SimpleFilterTest.php | 97 +++ .../Civi/FlexMailer/MailingPreviewTest.php | 156 ++++ .../phpunit/Civi/FlexMailer/ValidatorTest.php | 101 +++ ext/flexmailer/tests/phpunit/bootstrap.php | 64 ++ ext/flexmailer/xml/Menu/flexmailer.xml | 11 + 57 files changed, 5236 insertions(+), 1 deletion(-) create mode 100644 ext/flexmailer/CHANGELOG.md create mode 100644 ext/flexmailer/LICENSE.txt create mode 100644 ext/flexmailer/README.md create mode 100644 ext/flexmailer/docs/develop/CheckSendableEvent.md create mode 100644 ext/flexmailer/docs/develop/ComposeBatchEvent.md create mode 100644 ext/flexmailer/docs/develop/RunEvent.md create mode 100644 ext/flexmailer/docs/develop/SendBatchEvent.md create mode 100644 ext/flexmailer/docs/develop/WalkBatchesEvent.md create mode 100644 ext/flexmailer/docs/develop/index.md create mode 100644 ext/flexmailer/docs/index.md create mode 100644 ext/flexmailer/docs/install.md create mode 100644 ext/flexmailer/flexmailer.civix.php create mode 100644 ext/flexmailer/flexmailer.php create mode 100644 ext/flexmailer/info.xml create mode 100644 ext/flexmailer/mkdocs.yml create mode 100644 ext/flexmailer/phpunit.xml.dist create mode 100644 ext/flexmailer/settings/flexmailer.setting.php create mode 100644 ext/flexmailer/src/API/MailingPreview.php create mode 100644 ext/flexmailer/src/ClickTracker/ClickTrackerInterface.php create mode 100644 ext/flexmailer/src/ClickTracker/HtmlClickTracker.php create mode 100644 ext/flexmailer/src/ClickTracker/TextClickTracker.php create mode 100644 ext/flexmailer/src/Event/BaseEvent.php create mode 100644 ext/flexmailer/src/Event/CheckSendableEvent.php create mode 100644 ext/flexmailer/src/Event/ComposeBatchEvent.php create mode 100644 ext/flexmailer/src/Event/RunEvent.php create mode 100644 ext/flexmailer/src/Event/SendBatchEvent.php create mode 100644 ext/flexmailer/src/Event/WalkBatchesEvent.php create mode 100644 ext/flexmailer/src/FlexMailer.php create mode 100644 ext/flexmailer/src/FlexMailerTask.php create mode 100644 ext/flexmailer/src/Listener/Abdicator.php create mode 100644 ext/flexmailer/src/Listener/Attachments.php create mode 100644 ext/flexmailer/src/Listener/BaseListener.php create mode 100644 ext/flexmailer/src/Listener/BasicHeaders.php create mode 100644 ext/flexmailer/src/Listener/BounceTracker.php create mode 100644 ext/flexmailer/src/Listener/DefaultBatcher.php create mode 100644 ext/flexmailer/src/Listener/DefaultComposer.php create mode 100644 ext/flexmailer/src/Listener/DefaultSender.php create mode 100644 ext/flexmailer/src/Listener/HookAdapter.php create mode 100644 ext/flexmailer/src/Listener/OpenTracker.php create mode 100644 ext/flexmailer/src/Listener/RequiredFields.php create mode 100644 ext/flexmailer/src/Listener/RequiredTokens.php create mode 100644 ext/flexmailer/src/Listener/SimpleFilter.php create mode 100644 ext/flexmailer/src/Listener/TestPrefix.php create mode 100644 ext/flexmailer/src/Listener/ToHeader.php create mode 100644 ext/flexmailer/src/MailParams.php create mode 100644 ext/flexmailer/src/Services.php create mode 100644 ext/flexmailer/src/Validator.php create mode 100644 ext/flexmailer/tests/phpunit/Civi/FlexMailer/ClickTracker/HtmlClickTrackerTest.php create mode 100644 ext/flexmailer/tests/phpunit/Civi/FlexMailer/ClickTracker/TextClickTrackerTest.php create mode 100644 ext/flexmailer/tests/phpunit/Civi/FlexMailer/ConcurrentDeliveryTest.php create mode 100644 ext/flexmailer/tests/phpunit/Civi/FlexMailer/FlexMailerSystemTest.php create mode 100644 ext/flexmailer/tests/phpunit/Civi/FlexMailer/Listener/SimpleFilterTest.php create mode 100644 ext/flexmailer/tests/phpunit/Civi/FlexMailer/MailingPreviewTest.php create mode 100644 ext/flexmailer/tests/phpunit/Civi/FlexMailer/ValidatorTest.php create mode 100644 ext/flexmailer/tests/phpunit/bootstrap.php create mode 100644 ext/flexmailer/xml/Menu/flexmailer.xml diff --git a/.gitignore b/.gitignore index 781c6f3a83..402f2a0958 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ *~ *.bak .use-civicrm-setup -/ext/ +/ext/* !/ext/sequentialcreditnotes +!/ext/flexmailer backdrop/ bower_components CRM/Case/xml/configuration diff --git a/ext/flexmailer/CHANGELOG.md b/ext/flexmailer/CHANGELOG.md new file mode 100644 index 0000000000..5e4c808f4a --- /dev/null +++ b/ext/flexmailer/CHANGELOG.md @@ -0,0 +1,9 @@ +# Change Log + +## v0.2-alpha1 + +* Override core's `Mailing.preview` API to support rendering via + Flexmailer events. +* (BC Break) In the class `DefaultComposer`, change the signature for + `createMessageTemplates()` and `applyClickTracking()` to provide full + access to the event context (`$e`). diff --git a/ext/flexmailer/LICENSE.txt b/ext/flexmailer/LICENSE.txt new file mode 100644 index 0000000000..2a7f20c7ba --- /dev/null +++ b/ext/flexmailer/LICENSE.txt @@ -0,0 +1,667 @@ +Package: org.civicrm.flexmailer +Copyright (C) 2016, Tim Otten +Licensed under the GNU Affero Public License 3.0 (below). + +------------------------------------------------------------------------------- + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/ext/flexmailer/README.md b/ext/flexmailer/README.md new file mode 100644 index 0000000000..5f66e5b797 --- /dev/null +++ b/ext/flexmailer/README.md @@ -0,0 +1,12 @@ +# org.civicrm.flexmailer + +FlexMailer (`org.civicrm.flexmailer`) is an email delivery engine for CiviCRM v4.7+. It replaces the internal guts of CiviMail. It is a +drop-in replacement which enables *other* extensions to provide richer email features. + +* [Introduction](docs/index.md) +* [Installation](docs/install.md) +* [Development](docs/develop/index.md) + * [CheckSendableEvent](docs/develop/CheckSendableEvent.md) + * [WalkBatchesEvent](docs/develop/WalkBatchesEvent.md) + * [ComposeBatchEvent](docs/develop/ComposeBatchEvent.md) + * [SendBatchEvent](docs/develop/SendBatchEvent.md) diff --git a/ext/flexmailer/docs/develop/CheckSendableEvent.md b/ext/flexmailer/docs/develop/CheckSendableEvent.md new file mode 100644 index 0000000000..a36d83f58c --- /dev/null +++ b/ext/flexmailer/docs/develop/CheckSendableEvent.md @@ -0,0 +1,29 @@ + +The `CheckSendableEvent` (`EVENT_CHECK_SENDABLE`) determines whether a draft mailing is fully specified for delivery. + +For example, some jurisdictions require that email blasts provide contact +information for the organization (eg street address) and an opt-out link. +Traditionally, the check-sendable event will verify that this information is +provided through a CiviMail token (eg `{action.unsubscribeUrl}`). + +But what happens if you implement a new template language (e.g. Mustache) with +a different mail-merge notation? The validation will need to be different. +In this example, we verify the presence of a Mustache-style token, `{{unsubscribeUrl}}`. + +```php +addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__)); + $container->findDefinition('dispatcher')->addMethodCall('addListener', + array(\Civi\FlexMailer\Validator::EVENT_CHECK_SENDABLE, '_mustache_check_sendable') + ); +} + +function _mustache_check_sendable(\Civi\FlexMailer\Event\CheckSendableEvent $e) { + if ($e->getMailing()->template_type !== 'mustache') return; + + if (strpos('{{unsubscribeUrl}}', $e->getMailing()->body_html) === FALSE) { + $e->setError('body_html:unsubscribeUrl', E::ts('Please include the token {{unsubscribeUrl}}')); + } +} +``` diff --git a/ext/flexmailer/docs/develop/ComposeBatchEvent.md b/ext/flexmailer/docs/develop/ComposeBatchEvent.md new file mode 100644 index 0000000000..80bd17cc58 --- /dev/null +++ b/ext/flexmailer/docs/develop/ComposeBatchEvent.md @@ -0,0 +1,62 @@ +The `ComposeBatchEvent` builds the email messages. Each message is represented as a `FlexMailerTask` with a list of `MailParams`. + +Some listeners are "under the hood" -- they define less visible parts of the message, e.g. + + * `BasicHeaders` defines `Message-Id`, `Precedence`, `From`, `Reply-To`, and others. + * `BounceTracker` defines various headers for bounce-tracking. + * `OpenTracker` appends an HTML tracking code to any HTML messages. + +The heavy-lifting of composing the message content is also handled by a listener, such as +`DefaultComposer`. `DefaultComposer` replicates traditional CiviMail functionality: + + * Reads email content from `$mailing->body_text` and `$mailing->body_html`. + * Interprets tokens like `{contact.display_name}` and `{mailing.viewUrl}`. + * Loads data in batches. + * Post-processes the message with Smarty (if `CIVICRM_SMARTY` is enabled). + +The traditional CiviMail semantics have some problems -- e.g. the Smarty post-processing is incompatible with Smarty's +template cache, and it is difficult to securely post-process the message with Smarty. However, changing the behavior +would break existing templates. + +A major goal of FlexMailer is to facilitate a migration toward different template semantics. For example, an +extension might (naively) implement support for Mustache templates using: + +```php +addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__)); + $container->findDefinition('dispatcher')->addMethodCall('addListener', + array(\Civi\FlexMailer\FlexMailer::EVENT_COMPOSE, '_mustache_compose_batch') + ); +} + +function _mustache_compose_batch(\Civi\FlexMailer\Event\ComposeBatchEvent $event) { + if ($event->getMailing()->template_type !== 'mustache') return; + + $m = new Mustache_Engine(); + foreach ($event->getTasks() as $task) { + if ($task->hasContent()) continue; + $contact = civicrm_api3('Contact', 'getsingle', array( + 'id' => $task->getContactId(), + )); + $task->setMailParam('text', $m->render($event->getMailing()->body_text, $contact)); + $task->setMailParam('html', $m->render($event->getMailing()->body_html, $contact)); + } +} +``` + +This implementation is naive in a few ways -- it performs separate SQL queries for each recipient; it doesn't optimize +the template compilation; it has a very limited range of tokens; and it doesn't handle click-through tracking. For +more ideas about these issues, review `DefaultComposer`. + +> FIXME: Core's `TokenProcessor` is useful for batch-loading token data. +> However, you currently have to use `addMessage()` and `render()` to kick it +> off -- but those are based on CiviMail template notation. We should provide +> another function that doesn't depend on the template notation -- so that +> other templates can leverage our token library. + +> **Tip**: When you register a listener for `EVENT_COMPOSE`, note the weight. +> The default weight puts your listener in the middle of pipeline -- right +> before the `DefaultComposer`. However, you might want to position +> relative to other places -- e.g. `WEIGHT_PREPARE`, `WEIGHT_MAIN`, +> `WEIGHT_ALTER`, or `WEIGHT_END`. diff --git a/ext/flexmailer/docs/develop/RunEvent.md b/ext/flexmailer/docs/develop/RunEvent.md new file mode 100644 index 0000000000..a4fa5d25e0 --- /dev/null +++ b/ext/flexmailer/docs/develop/RunEvent.md @@ -0,0 +1,36 @@ +The `RunEvent` (`EVENT_RUN`) fires as soon as FlexMailer begins processing a job. + +CiviMail has a recurring task -- `Job.process_mailings` -- which identifies scheduled/pending mailings. It determines +the `Mailing` and `MailingJob` records, then passes control to `FlexMailer` to perform delivery. `FlexMailer` +immediately fires the `RunEvent`. + +!!! note "`RunEvent` fires for each cron-run." + + By default, FlexMailer uses `DefaultBatcher` which obeys the traditional CiviMail throttling behavior. This can + limit the number of deliveries performed within a single cron-run. If you reach this limit, then it stops + execution. However, after 5 or 10 minutes, a new *cron-run* begins. It passes control to FlexMailer again, and + then we pick up where we left off. This means that one `Mailing` and one `MailingJob` could require multiple + *cron-runs*. + + The `RunEvent` would fire for *every cron run*. + +To listen to the `RunEvent`: + +```php +addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__)); + $container->findDefinition('dispatcher')->addMethodCall('addListener', + array(\Civi\FlexMailer\FlexMailer::EVENT_RUN, '_example_run') + ); +} + +function _example_run(\Civi\FlexMailer\Event\RunEvent $event) { + printf("Starting work on job #%d for mailing #%d\n", $event->getJob()->id, $event->getMailing()->id); +} +``` + +!!! note "Stopping the `RunEvent` will stop FlexMailer." + + If you call `$event->stopPropagation()`, this will cause FlexMailer to + stop its delivery process. diff --git a/ext/flexmailer/docs/develop/SendBatchEvent.md b/ext/flexmailer/docs/develop/SendBatchEvent.md new file mode 100644 index 0000000000..dcba869b0b --- /dev/null +++ b/ext/flexmailer/docs/develop/SendBatchEvent.md @@ -0,0 +1,25 @@ +The `SendBatchEvent` (`EVENT_SEND`) takes a batch of recipients and messages, and it delivers the messages. For example, suppose you wanted to +replace the built-in delivery mechanism with a batch-oriented web-service: + +```php +addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__)); + $container->findDefinition('dispatcher')->addMethodCall('addListener', + array(\Civi\FlexMailer\FlexMailer::EVENT_SEND, '_example_send_batch') + ); +} + +function _example_send_batch(\Civi\FlexMailer\Event\SendBatchEvent $event) { + $event->stopPropagation(); // Disable standard delivery + + $context = stream_context_create(array( + 'http' => array( + 'method' => 'POST', + 'header' => 'Content-type: application/vnd.php.serialize', + 'content' => serialize($event->getTasks()), + ), + )); + return file_get_contents('https://example.org/batch-delivery', false, $context); +} +``` diff --git a/ext/flexmailer/docs/develop/WalkBatchesEvent.md b/ext/flexmailer/docs/develop/WalkBatchesEvent.md new file mode 100644 index 0000000000..346aefdc7d --- /dev/null +++ b/ext/flexmailer/docs/develop/WalkBatchesEvent.md @@ -0,0 +1,26 @@ +The `WalkBatchesEvent` examines the recipient list and pulls out a subset for whom you want to send email. This is useful if you need strategies for +chunking-out deliveries. + +The basic formula for defining your own batch logic is: + +```php +addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__)); + $container->findDefinition('dispatcher')->addMethodCall('addListener', + array(\Civi\FlexMailer\FlexMailer::EVENT_WALK, '_example_walk_batches') + ); +} + +function _example_walk_batches(\Civi\FlexMailer\Event\WalkBatchesEvent $event) { + $event->stopPropagation(); // Disable standard delivery + + while (...) { + $tasks = array(); + $task[] = new FlexMailerTask(...); + $task[] = new FlexMailerTask(...); + $task[] = new FlexMailerTask(...); + $event->visit($tasks); + } +} +``` diff --git a/ext/flexmailer/docs/develop/index.md b/ext/flexmailer/docs/develop/index.md new file mode 100644 index 0000000000..8ef6b8b09d --- /dev/null +++ b/ext/flexmailer/docs/develop/index.md @@ -0,0 +1,136 @@ +## Unit tests + +The [headless unit tests](https://docs.civicrm.org/dev/en/latest/testing/#headless) are based on PHPUnit and `cv`. Simply run: + +``` +$ phpunit5 +``` + +## Events + +!!! tip "Symfony Events" + + This documentation references the [Symfony EventDispatcher](http://symfony.com/components/EventDispatcher). + If this is unfamiliar, you can read [a general introduction to Symfony events](http://symfony.com/doc/2.7/components/event_dispatcher.html) + or [a specific introduction about CiviCRM and Symfony events](https://docs.civicrm.org/dev/en/latest/hooks/setup/symfony/). + +FlexMailer is an *event* based delivery system. It defines a few events: + +* [CheckSendableEvent](CheckSendableEvent.md): In this event, one examines a draft mailing to determine if it is complete enough to deliver. +* [RunEvent](RunEvent.md): When a cron-worker starts processing a `MailingJob`, this event fires. It can be used to initialize resources... or to completely circumvent the normal process. +* [WalkBatchesEvent](WalkBatchesEvent.md): In this event, one examines the recipient list and pulls out a subset for whom you want to send email. +* [ComposeBatchEvent](ComposeBatchEvent.md): In this event, one examines the mail content and the list of recipients -- then composes a batch of fully-formed email messages. +* [SendBatchEvent](SendBatchEvent.md): In this event, one takes a batch of fully-formed email messages and delivers the messages. + +These events are not conceived in the same way as a typical *CiviCRM hook*; rather, they resemble *pipelines*. For each event, several listeners +have an opportunity to weigh-in, and the *order* of the listeners is important. As such, it helps to *inspect* the list of listeners. You can do +this with the CLI command, `cv`: + +``` +$ cv debug:event-dispatcher /flexmail/ +[Event] civi.flexmailer.checkSendable ++-------+------------------------------------------------------------+ +| Order | Callable | ++-------+------------------------------------------------------------+ +| #1 | Civi\FlexMailer\Listener\Abdicator->onCheckSendable() | +| #2 | Civi\FlexMailer\Listener\RequiredFields->onCheckSendable() | +| #3 | Civi\FlexMailer\Listener\RequiredTokens->onCheckSendable() | ++-------+------------------------------------------------------------+ + +[Event] civi.flexmailer.walk ++-------+---------------------------------------------------+ +| Order | Callable | ++-------+---------------------------------------------------+ +| #1 | Civi\FlexMailer\Listener\DefaultBatcher->onWalk() | ++-------+---------------------------------------------------+ + +[Event] civi.flexmailer.compose ++-------+-------------------------------------------------------+ +| Order | Callable | ++-------+-------------------------------------------------------+ +| #1 | Civi\FlexMailer\Listener\BasicHeaders->onCompose() | +| #2 | Civi\FlexMailer\Listener\ToHeader->onCompose() | +| #3 | Civi\FlexMailer\Listener\BounceTracker->onCompose() | +| #4 | Civi\FlexMailer\Listener\DefaultComposer->onCompose() | +| #5 | Civi\FlexMailer\Listener\Attachments->onCompose() | +| #6 | Civi\FlexMailer\Listener\OpenTracker->onCompose() | +| #7 | Civi\FlexMailer\Listener\HookAdapter->onCompose() | ++-------+-------------------------------------------------------+ + +[Event] civi.flexmailer.send ++-------+--------------------------------------------------+ +| Order | Callable | ++-------+--------------------------------------------------+ +| #1 | Civi\FlexMailer\Listener\DefaultSender->onSend() | ++-------+--------------------------------------------------+ +``` + +The above listing shows the default set of listeners at time of writing. (Run the command yourself to see how they appear on your system.) +The default listeners behave in basically the same way as CiviMail's traditional BAO-based delivery system (respecting `mailerJobSize`, +`mailThrottleTime`, `mailing_backend`, `hook_civicrm_alterMailParams`, etal). + +There are a few tricks for manipulating the pipeline: + +* __Register new listeners__. Each event has its own documentation which describes how to do this. +* __Manage the priority__. When registering a listener, the `addListener()` function accepts a `$priority` integer. Use this to move up or down the pipeline. + + !!! note "Priority vs Order" + + When writing code, you will set the *priority* of a listener. The default is `0`, and the usual range is `2000` (first) to `-2000` (last). + + + + At runtime, the `EventDispatcher` will take all the listeners and sort them by priority. This produces the *order*, which simply counts up (`1`, `2`, `3`, ...). + +* __Alter a listener__. Most listeners are *services*, and you can manipulate options on these services. For example, suppose you wanted to replace the default bounce-tracking mechanism. + Here's a simple way to disable the default `BounceTracker`: + + ```php + setActive(FALSE); + ``` + + Of course, this change needs to be made before the listener runs. You might use a global hook (like `hook_civicrm_config`), or you might + have your own listener which disables `civi_flexmailer_bounce_tracker` and adds its own bounce-tracking. + + Most FlexMailer services support `setActive()`, which enables you to completely replace them. + + Additionally, some services have their own richer methods. In this example, we modify the list of required tokens: + + ```php + getRequiredTokens(); + + unset($tokens['domain.address']); + + \Civi::service('civi_flexmailer_required_tokens') + ->setRequiredTokens($tokens); + ``` + +## Services + +Most features in FlexMailer are implemented by *services*, and you can override or manipulate these features if you understand the corresponding service. +For more detailed information about how to manipulate a service, consult its docblocks. + +* Listener services (`CheckSendableEvent`) + * `civi_flexmailer_required_fields` (`RequiredFields.php`): Check for fields like "Subject" and "From". + * `civi_flexmailer_required_tokens` (`RequiredTokens.php`): Check for tokens like `{action.unsubscribeUrl}` (in `traditional` mailings). +* Listener services (`WalkBatchesEvent`) + * `civi_flexmailer_default_batcher` (`DefaultBatcher.php`): Split the recipient list into smaller batches (per CiviMail settings) +* Listener services (`ComposeBatchEvent`) + * `civi_flexmailer_basic_headers` (`BasicHeaders.php`): Add `From:`, `Reply-To:`, etc + * `civi_flexmailer_to_header` (`ToHeader.php`): Add `To:` header + * `civi_flexmailer_bounce_tracker` (`BounceTracker.php`): Add bounce-tracking codes + * `civi_flexmailer_default_composer` (`DefaultComposer.php`): Read the email template and evaluate any tokens (based on CiviMail tokens) + * `civi_flexmailer_attachments` (`Attachments.php`): Add attachments + * `civi_flexmailer_open_tracker` (`OpenTracker.php`): Add open-tracking codes + * `civi_flexmailer_test_prefix` (`TestPrefix.php`): Add a prefix to any test mailings + * `civi_flexmailer_hooks` (`HookAdapter.php`): Backward compatibility with `hook_civicrm_alterMailParams` +* Listener services (`SendBatchEvent`) + * `civi_flexmailer_default_sender` (`DefaultSender.php`): Send the batch using CiviCRM's default delivery service +* Other services + * `civi_flexmailer_html_click_tracker` (`HtmlClickTracker.php`): Add click-tracking codes (for HTML messages) + * `civi_flexmailer_text_click_tracker` (`TextClickTracker.php`): Add click-tracking codes (for plain-text messages) + * `civi_flexmailer_api_overrides` (`Services.php.php`): Alter the `Mailing` APIs diff --git a/ext/flexmailer/docs/index.md b/ext/flexmailer/docs/index.md new file mode 100644 index 0000000000..88a1521a3a --- /dev/null +++ b/ext/flexmailer/docs/index.md @@ -0,0 +1,13 @@ +FlexMailer (`org.civicrm.flexmailer`) is an email delivery engine for CiviCRM v4.7+. It replaces the internal guts of CiviMail. It is a +drop-in replacement which enables *other* extensions to provide richer email features. + +By default, FlexMailer supports the same user interfaces, delivery algorithms, and use-cases as CiviMail. After activating FlexMailer, an +administrator does not need to take any special actions. + +The distinguishing improvement here is under-the-hood: it provides better APIs and events for extension-developers. For example, +other extensions might: + +* Change the template language +* Manipulate tracking codes +* Rework the delivery mechanism +* Redefine the batching algorithm diff --git a/ext/flexmailer/docs/install.md b/ext/flexmailer/docs/install.md new file mode 100644 index 0000000000..4f385e88b2 --- /dev/null +++ b/ext/flexmailer/docs/install.md @@ -0,0 +1,19 @@ +To download the latest alpha or beta version: + +```bash +$ cv dl --dev flexmailer +``` + +To download the latest, bleeding-edge code: + +```bash +$ cv dl org.civicrm.flexmailer@https://github.com/civicrm/org.civicrm.flexmailer/archive/master.zip +``` + +To download the latest, bleeding-edge code from git: + +```bash +$ cd $(cv path -x .) +$ git clone https://github.com/civicrm/org.civicrm.flexmailer.git +$ cv en flexmailer +``` diff --git a/ext/flexmailer/flexmailer.civix.php b/ext/flexmailer/flexmailer.civix.php new file mode 100644 index 0000000000..9fb3aa28e3 --- /dev/null +++ b/ext/flexmailer/flexmailer.civix.php @@ -0,0 +1,477 @@ +getUrl(self::LONG_NAME), '/'); + } + return CRM_Core_Resources::singleton()->getUrl(self::LONG_NAME, $file); + } + + /** + * Get the path of a resource file (in this extension). + * + * @param string|NULL $file + * Ex: NULL. + * Ex: 'css/foo.css'. + * @return string + * Ex: '/var/www/example.org/sites/default/ext/org.example.foo'. + * Ex: '/var/www/example.org/sites/default/ext/org.example.foo/css/foo.css'. + */ + public static function path($file = NULL) { + // return CRM_Core_Resources::singleton()->getPath(self::LONG_NAME, $file); + return __DIR__ . ($file === NULL ? '' : (DIRECTORY_SEPARATOR . $file)); + } + + /** + * Get the name of a class within this extension. + * + * @param string $suffix + * Ex: 'Page_HelloWorld' or 'Page\\HelloWorld'. + * @return string + * Ex: 'CRM_Foo_Page_HelloWorld'. + */ + public static function findClass($suffix) { + return self::CLASS_PREFIX . '_' . str_replace('\\', '_', $suffix); + } + +} + +use CRM_Flexmailer_ExtensionUtil as E; + +/** + * (Delegated) Implements hook_civicrm_config(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_config + */ +function _flexmailer_civix_civicrm_config(&$config = NULL) { + static $configured = FALSE; + if ($configured) { + return; + } + $configured = TRUE; + + $template =& CRM_Core_Smarty::singleton(); + + $extRoot = dirname(__FILE__) . DIRECTORY_SEPARATOR; + $extDir = $extRoot . 'templates'; + + if (is_array($template->template_dir)) { + array_unshift($template->template_dir, $extDir); + } + else { + $template->template_dir = [$extDir, $template->template_dir]; + } + + $include_path = $extRoot . PATH_SEPARATOR . get_include_path(); + set_include_path($include_path); +} + +/** + * (Delegated) Implements hook_civicrm_xmlMenu(). + * + * @param $files array(string) + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_xmlMenu + */ +function _flexmailer_civix_civicrm_xmlMenu(&$files) { + foreach (_flexmailer_civix_glob(__DIR__ . '/xml/Menu/*.xml') as $file) { + $files[] = $file; + } +} + +/** + * Implements hook_civicrm_install(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_install + */ +function _flexmailer_civix_civicrm_install() { + _flexmailer_civix_civicrm_config(); + if ($upgrader = _flexmailer_civix_upgrader()) { + $upgrader->onInstall(); + } +} + +/** + * Implements hook_civicrm_postInstall(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_postInstall + */ +function _flexmailer_civix_civicrm_postInstall() { + _flexmailer_civix_civicrm_config(); + if ($upgrader = _flexmailer_civix_upgrader()) { + if (is_callable([$upgrader, 'onPostInstall'])) { + $upgrader->onPostInstall(); + } + } +} + +/** + * Implements hook_civicrm_uninstall(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_uninstall + */ +function _flexmailer_civix_civicrm_uninstall() { + _flexmailer_civix_civicrm_config(); + if ($upgrader = _flexmailer_civix_upgrader()) { + $upgrader->onUninstall(); + } +} + +/** + * (Delegated) Implements hook_civicrm_enable(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_enable + */ +function _flexmailer_civix_civicrm_enable() { + _flexmailer_civix_civicrm_config(); + if ($upgrader = _flexmailer_civix_upgrader()) { + if (is_callable([$upgrader, 'onEnable'])) { + $upgrader->onEnable(); + } + } +} + +/** + * (Delegated) Implements hook_civicrm_disable(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_disable + * @return mixed + */ +function _flexmailer_civix_civicrm_disable() { + _flexmailer_civix_civicrm_config(); + if ($upgrader = _flexmailer_civix_upgrader()) { + if (is_callable([$upgrader, 'onDisable'])) { + $upgrader->onDisable(); + } + } +} + +/** + * (Delegated) Implements hook_civicrm_upgrade(). + * + * @param $op string, the type of operation being performed; 'check' or 'enqueue' + * @param $queue CRM_Queue_Queue, (for 'enqueue') the modifiable list of pending up upgrade tasks + * + * @return mixed + * based on op. for 'check', returns array(boolean) (TRUE if upgrades are pending) + * for 'enqueue', returns void + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_upgrade + */ +function _flexmailer_civix_civicrm_upgrade($op, CRM_Queue_Queue $queue = NULL) { + if ($upgrader = _flexmailer_civix_upgrader()) { + return $upgrader->onUpgrade($op, $queue); + } +} + +/** + * @return CRM_Flexmailer_Upgrader + */ +function _flexmailer_civix_upgrader() { + if (!file_exists(__DIR__ . '/CRM/Flexmailer/Upgrader.php')) { + return NULL; + } + else { + return CRM_Flexmailer_Upgrader_Base::instance(); + } +} + +/** + * Search directory tree for files which match a glob pattern. + * + * Note: Dot-directories (like "..", ".git", or ".svn") will be ignored. + * Note: In Civi 4.3+, delegate to CRM_Utils_File::findFiles() + * + * @param string $dir base dir + * @param string $pattern , glob pattern, eg "*.txt" + * + * @return array(string) + */ +function _flexmailer_civix_find_files($dir, $pattern) { + if (is_callable(['CRM_Utils_File', 'findFiles'])) { + return CRM_Utils_File::findFiles($dir, $pattern); + } + + $todos = [$dir]; + $result = []; + while (!empty($todos)) { + $subdir = array_shift($todos); + foreach (_flexmailer_civix_glob("$subdir/$pattern") as $match) { + if (!is_dir($match)) { + $result[] = $match; + } + } + if ($dh = opendir($subdir)) { + while (FALSE !== ($entry = readdir($dh))) { + $path = $subdir . DIRECTORY_SEPARATOR . $entry; + if ($entry[0] == '.') { + } + elseif (is_dir($path)) { + $todos[] = $path; + } + } + closedir($dh); + } + } + return $result; +} + +/** + * (Delegated) Implements hook_civicrm_managed(). + * + * Find any *.mgd.php files, merge their content, and return. + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_managed + */ +function _flexmailer_civix_civicrm_managed(&$entities) { + $mgdFiles = _flexmailer_civix_find_files(__DIR__, '*.mgd.php'); + sort($mgdFiles); + foreach ($mgdFiles as $file) { + $es = include $file; + foreach ($es as $e) { + if (empty($e['module'])) { + $e['module'] = E::LONG_NAME; + } + if (empty($e['params']['version'])) { + $e['params']['version'] = '3'; + } + $entities[] = $e; + } + } +} + +/** + * (Delegated) Implements hook_civicrm_caseTypes(). + * + * Find any and return any files matching "xml/case/*.xml" + * + * Note: This hook only runs in CiviCRM 4.4+. + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_caseTypes + */ +function _flexmailer_civix_civicrm_caseTypes(&$caseTypes) { + if (!is_dir(__DIR__ . '/xml/case')) { + return; + } + + foreach (_flexmailer_civix_glob(__DIR__ . '/xml/case/*.xml') as $file) { + $name = preg_replace('/\.xml$/', '', basename($file)); + if ($name != CRM_Case_XMLProcessor::mungeCaseType($name)) { + $errorMessage = sprintf("Case-type file name is malformed (%s vs %s)", $name, CRM_Case_XMLProcessor::mungeCaseType($name)); + throw new CRM_Core_Exception($errorMessage); + } + $caseTypes[$name] = [ + 'module' => E::LONG_NAME, + 'name' => $name, + 'file' => $file, + ]; + } +} + +/** + * (Delegated) Implements hook_civicrm_angularModules(). + * + * Find any and return any files matching "ang/*.ang.php" + * + * Note: This hook only runs in CiviCRM 4.5+. + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_angularModules + */ +function _flexmailer_civix_civicrm_angularModules(&$angularModules) { + if (!is_dir(__DIR__ . '/ang')) { + return; + } + + $files = _flexmailer_civix_glob(__DIR__ . '/ang/*.ang.php'); + foreach ($files as $file) { + $name = preg_replace(':\.ang\.php$:', '', basename($file)); + $module = include $file; + if (empty($module['ext'])) { + $module['ext'] = E::LONG_NAME; + } + $angularModules[$name] = $module; + } +} + +/** + * (Delegated) Implements hook_civicrm_themes(). + * + * Find any and return any files matching "*.theme.php" + */ +function _flexmailer_civix_civicrm_themes(&$themes) { + $files = _flexmailer_civix_glob(__DIR__ . '/*.theme.php'); + foreach ($files as $file) { + $themeMeta = include $file; + if (empty($themeMeta['name'])) { + $themeMeta['name'] = preg_replace(':\.theme\.php$:', '', basename($file)); + } + if (empty($themeMeta['ext'])) { + $themeMeta['ext'] = E::LONG_NAME; + } + $themes[$themeMeta['name']] = $themeMeta; + } +} + +/** + * Glob wrapper which is guaranteed to return an array. + * + * The documentation for glob() says, "On some systems it is impossible to + * distinguish between empty match and an error." Anecdotally, the return + * result for an empty match is sometimes array() and sometimes FALSE. + * This wrapper provides consistency. + * + * @link http://php.net/glob + * @param string $pattern + * + * @return array, possibly empty + */ +function _flexmailer_civix_glob($pattern) { + $result = glob($pattern); + return is_array($result) ? $result : []; +} + +/** + * Inserts a navigation menu item at a given place in the hierarchy. + * + * @param array $menu - menu hierarchy + * @param string $path - path to parent of this item, e.g. 'my_extension/submenu' + * 'Mailing', or 'Administer/System Settings' + * @param array $item - the item to insert (parent/child attributes will be + * filled for you) + * + * @return bool + */ +function _flexmailer_civix_insert_navigation_menu(&$menu, $path, $item) { + // If we are done going down the path, insert menu + if (empty($path)) { + $menu[] = [ + 'attributes' => array_merge([ + 'label' => CRM_Utils_Array::value('name', $item), + 'active' => 1, + ], $item), + ]; + return TRUE; + } + else { + // Find an recurse into the next level down + $found = FALSE; + $path = explode('/', $path); + $first = array_shift($path); + foreach ($menu as $key => &$entry) { + if ($entry['attributes']['name'] == $first) { + if (!isset($entry['child'])) { + $entry['child'] = []; + } + $found = _flexmailer_civix_insert_navigation_menu($entry['child'], implode('/', $path), $item); + } + } + return $found; + } +} + +/** + * (Delegated) Implements hook_civicrm_navigationMenu(). + */ +function _flexmailer_civix_navigationMenu(&$nodes) { + if (!is_callable(['CRM_Core_BAO_Navigation', 'fixNavigationMenu'])) { + _flexmailer_civix_fixNavigationMenu($nodes); + } +} + +/** + * Given a navigation menu, generate navIDs for any items which are + * missing them. + */ +function _flexmailer_civix_fixNavigationMenu(&$nodes) { + $maxNavID = 1; + array_walk_recursive($nodes, function($item, $key) use (&$maxNavID) { + if ($key === 'navID') { + $maxNavID = max($maxNavID, $item); + } + }); + _flexmailer_civix_fixNavigationMenuItems($nodes, $maxNavID, NULL); +} + +function _flexmailer_civix_fixNavigationMenuItems(&$nodes, &$maxNavID, $parentID) { + $origKeys = array_keys($nodes); + foreach ($origKeys as $origKey) { + if (!isset($nodes[$origKey]['attributes']['parentID']) && $parentID !== NULL) { + $nodes[$origKey]['attributes']['parentID'] = $parentID; + } + // If no navID, then assign navID and fix key. + if (!isset($nodes[$origKey]['attributes']['navID'])) { + $newKey = ++$maxNavID; + $nodes[$origKey]['attributes']['navID'] = $newKey; + $nodes[$newKey] = $nodes[$origKey]; + unset($nodes[$origKey]); + $origKey = $newKey; + } + if (isset($nodes[$origKey]['child']) && is_array($nodes[$origKey]['child'])) { + _flexmailer_civix_fixNavigationMenuItems($nodes[$origKey]['child'], $maxNavID, $nodes[$origKey]['attributes']['navID']); + } + } +} + +/** + * (Delegated) Implements hook_civicrm_alterSettingsFolders(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_alterSettingsFolders + */ +function _flexmailer_civix_civicrm_alterSettingsFolders(&$metaDataFolders = NULL) { + $settingsDir = __DIR__ . DIRECTORY_SEPARATOR . 'settings'; + if (!in_array($settingsDir, $metaDataFolders) && is_dir($settingsDir)) { + $metaDataFolders[] = $settingsDir; + } +} + +/** + * (Delegated) Implements hook_civicrm_entityTypes(). + * + * Find any *.entityType.php files, merge their content, and return. + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_entityTypes + */ +function _flexmailer_civix_civicrm_entityTypes(&$entityTypes) { + $entityTypes = array_merge($entityTypes, []); +} diff --git a/ext/flexmailer/flexmailer.php b/ext/flexmailer/flexmailer.php new file mode 100644 index 0000000000..f8850486f7 --- /dev/null +++ b/ext/flexmailer/flexmailer.php @@ -0,0 +1,162 @@ + E::ts('Flexmailer Settings'), + 'name' => 'flexmailer_settings', + 'permission' => 'administer CiviCRM', + 'child' => [], + 'operator' => 'AND', + 'separator' => 0, + 'url' => CRM_Utils_System::url('civicrm/admin/setting/flexmailer', 'reset=1', TRUE), + ]); + _flexmailer_civix_navigationMenu($menu); +} + +/** + * Implements hook_civicrm_container(). + */ +function flexmailer_civicrm_container($container) { + $container->addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__)); + \Civi\FlexMailer\Services::registerServices($container); +} + +/** + * Get a list of delivery options for traditional mailings. + * + * @return array + * Array (string $machineName => string $label). + */ +function _flexmailer_traditional_options() { + return array( + 'auto' => E::ts('Automatic'), + 'bao' => E::ts('CiviMail BAO'), + 'flexmailer' => E::ts('Flexmailer Pipeline'), + ); +} diff --git a/ext/flexmailer/info.xml b/ext/flexmailer/info.xml new file mode 100644 index 0000000000..f89dbd026e --- /dev/null +++ b/ext/flexmailer/info.xml @@ -0,0 +1,34 @@ + + + flexmailer + FlexMailer + Flexible APIs for email delivery + AGPL-3.0 + + Tim Otten + totten@civicrm.org + + + https://github.com/civicrm/org.civicrm.flexmailer + https://docs.civicrm.org/flexmailer/en/latest/ + http://civicrm.stackexchange.com/ + http://www.gnu.org/licenses/agpl-3.0.html + + 2019-11-26 + 1.1.1 + alpha + + FlexMailer is an email delivery engine which replaces the internal guts + of CiviMail. It is a drop-in replacement which enables *other* extensions + to provide richer email features. + + + 5.13 + + + + + + CRM/Flexmailer + + diff --git a/ext/flexmailer/mkdocs.yml b/ext/flexmailer/mkdocs.yml new file mode 100644 index 0000000000..968689337e --- /dev/null +++ b/ext/flexmailer/mkdocs.yml @@ -0,0 +1,28 @@ +site_name: FlexMailer +repo_url: https://github.com/civicrm/org.civicrm.flexmailer +theme: + name: material + +nav: +- Introduction: index.md +- Installation: install.md +- Development: + - Overview: develop/index.md + - CheckSendableEvent: develop/CheckSendableEvent.md + - RunEvent: develop/RunEvent.md + - WalkBatchesEvent: develop/WalkBatchesEvent.md + - ComposeBatchEvent: develop/ComposeBatchEvent.md + - SendBatchEvent: develop/SendBatchEvent.md + +markdown_extensions: + - attr_list + - admonition + - def_list + - codehilite + - toc: + permalink: true + - pymdownx.superfences + - pymdownx.inlinehilite + - pymdownx.tilde + - pymdownx.betterem + - pymdownx.mark diff --git a/ext/flexmailer/phpunit.xml.dist b/ext/flexmailer/phpunit.xml.dist new file mode 100644 index 0000000000..fc8f870b72 --- /dev/null +++ b/ext/flexmailer/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + ./tests/phpunit + + + + + ./ + + + + + + + + diff --git a/ext/flexmailer/settings/flexmailer.setting.php b/ext/flexmailer/settings/flexmailer.setting.php new file mode 100644 index 0000000000..ea227d9ef0 --- /dev/null +++ b/ext/flexmailer/settings/flexmailer.setting.php @@ -0,0 +1,28 @@ + [ + 'group_name' => 'Flexmailer Preferences', + 'group' => 'flexmailer', + 'name' => 'flexmailer_traditional', + 'type' => 'String', + 'html_type' => 'select', + 'html_attributes' => ['class' => 'crm-select2'], + 'pseudoconstant' => ['callback' => '_flexmailer_traditional_options'], + 'default' => 'auto', + 'add' => '5.13', + 'title' => E::ts('Traditional Mailing Handler'), + 'is_domain' => 1, + 'is_contact' => 0, + 'description' => E::ts('For greater backward-compatibility, process "traditional" mailings with the CiviMail\'s hard-coded BAO.') . '
' + . E::ts('For greater forward-compatibility, process "traditional" mailings with Flexmailer\'s extensible pipeline.'), + 'help_text' => NULL, + 'settings_pages' => [ + 'flexmailer' => [ + 'weight' => 5, + ], + ], + ], +]; diff --git a/ext/flexmailer/src/API/MailingPreview.php b/ext/flexmailer/src/API/MailingPreview.php new file mode 100644 index 0000000000..cdd11ac428 --- /dev/null +++ b/ext/flexmailer/src/API/MailingPreview.php @@ -0,0 +1,78 @@ +id = $mailingID; + $mailing->find(TRUE); + } + else { + $mailing->copyValues($params); + } + + if (!Abdicator::isFlexmailPreferred($mailing)) { + require_once 'api/v3/Mailing.php'; + return civicrm_api3_mailing_preview($params); + } + + $contactID = \CRM_Utils_Array::value('contact_id', $params, + \CRM_Core_Session::singleton()->get('userID')); + + $job = new \CRM_Mailing_BAO_MailingJob(); + $job->mailing_id = $mailing->id ?: NULL; + $job->is_test = 1; + $job->status = 'Complete'; + // $job->save(); + + $flexMailer = new FlexMailer(array( + 'is_preview' => TRUE, + 'mailing' => $mailing, + 'job' => $job, + 'attachments' => \CRM_Core_BAO_File::getEntityFile('civicrm_mailing', + $mailing->id), + )); + + if (count($flexMailer->validate()) > 0) { + throw new \CRM_Core_Exception("FlexMailer cannot execute: invalid context"); + } + + $task = new FlexMailerTask($job->id, $contactID, 'fakehash', + 'placeholder@example.com'); + + $flexMailer->fireComposeBatch(array($task)); + + return civicrm_api3_create_success(array( + 'id' => isset($params['id']) ? $params['id'] : NULL, + 'contact_id' => $contactID, + 'subject' => $task->getMailParam('Subject'), + 'body_html' => $task->getMailParam('html'), + 'body_text' => $task->getMailParam('text'), + // Flag our role in processing this - to support tests. + '_rendered_by_' => 'flexmailer', + )); + } + +} diff --git a/ext/flexmailer/src/ClickTracker/ClickTrackerInterface.php b/ext/flexmailer/src/ClickTracker/ClickTrackerInterface.php new file mode 100644 index 0000000000..50d6551dda --- /dev/null +++ b/ext/flexmailer/src/ClickTracker/ClickTrackerInterface.php @@ -0,0 +1,31 @@ + string $newHtmlUrl. + * @return mixed + * String, HTML. + */ + public static function replaceHrefUrls($html, $replace) { + $useNoFollow = TRUE; + $callback = function ($matches) use ($replace, $useNoFollow) { + $replacement = $replace($matches[2]); + + // See: https://github.com/civicrm/civicrm-core/pull/12561 + // If we track click-throughs on a link, then don't encourage search-engines to traverse them. + // At a policy level, I'm not sure I completely agree, but this keeps things consistent. + // You can tell if we're tracking a link because $replace() yields a diff URL. + $noFollow = ''; + if ($useNoFollow && $replacement !== $matches[2]) { + $noFollow = " rel='nofollow'"; + } + + return $matches[1] . $replacement . $matches[3] . $noFollow; + }; + + // Find anything like href="..." or href='...' inside a tag. + $tmp = preg_replace_callback( + ';(\<[^>]*href *= *")([^">]+)(");', $callback, $html); + return preg_replace_callback( + ';(\<[^>]*href *= *\')([^\'>]+)(\');', $callback, $tmp); + } + + // /** + // * Find URL expressions; replace them with tracked URLs. + // * + // * @param string $msg + // * @param int $mailing_id + // * @param int|string $queue_id + // * @param bool $html + // * @return string + // * Updated $msg + // */ + // public static function scanAndReplace_old($msg, $mailing_id, $queue_id, $html = FALSE) { + // + // $protos = '(https?|ftp)'; + // $letters = '\w'; + // $gunk = '/#~:.?+=&%@!\-'; + // $punc = '.:?\-'; + // $any = "{$letters}{$gunk}{$punc}"; + // if ($html) { + // $pattern = "{\\b(href=([\"'])?($protos:[$any]+?(?=[$punc]*[^$any]|$))([\"'])?)}im"; + // } + // else { + // $pattern = "{\\b($protos:[$any]+?(?=[$punc]*[^$any]|$))}eim"; + // } + // + // $trackURL = \CRM_Mailing_BAO_TrackableURL::getTrackerURL('\\1', $mailing_id, $queue_id); + // $replacement = $html ? ("href=\"{$trackURL}\"") : ("\"{$trackURL}\""); + // + // $msg = preg_replace($pattern, $replacement, $msg); + // if ($html) { + // $msg = htmlentities($msg, ENT_NOQUOTES); + // } + // return $msg; + // } + +} diff --git a/ext/flexmailer/src/ClickTracker/TextClickTracker.php b/ext/flexmailer/src/ClickTracker/TextClickTracker.php new file mode 100644 index 0000000000..3f907e55e9 --- /dev/null +++ b/ext/flexmailer/src/ClickTracker/TextClickTracker.php @@ -0,0 +1,47 @@ + string $newUrl. + * @return mixed + * String, text. + */ + public static function replaceTextUrls($text, $replace) { + $callback = function ($matches) use ($replace) { + // ex: $matches[0] == 'http://foo.com' + return $replace($matches[0]); + }; + // Find any HTTP(S) URLs in the text. + // return preg_replace_callback('/\b(?:(?:https?):\/\/|www\.|ftp\.)[-A-Z0-9+&@#\/%=~_|$?!:,.]*[A-Z0-9+&@#\/%=~_|$]/i', $callback, $tex + return preg_replace_callback('/\b(?:(?:https?):\/\/)[-A-Z0-9+&@#\/%=~_|$?!:,.{}\[\];]*[A-Z0-9+&@#\/%=~_|${}\[\];]/i', + $callback, $text); + } + +} diff --git a/ext/flexmailer/src/Event/BaseEvent.php b/ext/flexmailer/src/Event/BaseEvent.php new file mode 100644 index 0000000000..3ab35543ad --- /dev/null +++ b/ext/flexmailer/src/Event/BaseEvent.php @@ -0,0 +1,56 @@ +context = $context; + } + + /** + * @return \CRM_Mailing_BAO_Mailing + */ + public function getMailing() { + return $this->context['mailing']; + } + + /** + * @return \CRM_Mailing_BAO_MailingJob + */ + public function getJob() { + return $this->context['job']; + } + + /** + * @return array|NULL + */ + public function getAttachments() { + return $this->context['attachments']; + } + +} diff --git a/ext/flexmailer/src/Event/CheckSendableEvent.php b/ext/flexmailer/src/Event/CheckSendableEvent.php new file mode 100644 index 0000000000..95bdeb7b87 --- /dev/null +++ b/ext/flexmailer/src/Event/CheckSendableEvent.php @@ -0,0 +1,87 @@ + 'The Subject field is blank'). + * Example keys: 'subject', 'name', 'from_name', 'from_email', 'body', 'body_html:unsubscribeUrl'. + */ + protected $errors = array(); + + /** + * CheckSendableEvent constructor. + * @param array $context + */ + public function __construct(array $context) { + $this->context = $context; + } + + /** + * @return \CRM_Mailing_BAO_Mailing + */ + public function getMailing() { + return $this->context['mailing']; + } + + /** + * @return array|NULL + */ + public function getAttachments() { + return $this->context['attachments']; + } + + public function setError($key, $message) { + $this->errors[$key] = $message; + return $this; + } + + public function getErrors() { + return $this->errors; + } + + /** + * Get the full, combined content of the header, body, and footer. + * + * @param string $field + * Name of the field -- either 'body_text' or 'body_html'. + * @return string|NULL + * Either the combined header+body+footer, or NULL if there is no body. + */ + public function getFullBody($field) { + if ($field !== 'body_text' && $field !== 'body_html') { + throw new \RuntimeException("getFullBody() only supports body_text and body_html"); + } + $mailing = $this->getMailing(); + $header = $mailing->header_id && $mailing->header_id != 'null' ? \CRM_Mailing_BAO_Component::findById($mailing->header_id) : NULL; + $footer = $mailing->footer_id && $mailing->footer_id != 'null' ? \CRM_Mailing_BAO_Component::findById($mailing->footer_id) : NULL; + if (empty($mailing->{$field})) { + return NULL; + } + return ($header ? $header->{$field} : '') . $mailing->{$field} . ($footer ? $footer->{$field} : ''); + } + +} diff --git a/ext/flexmailer/src/Event/ComposeBatchEvent.php b/ext/flexmailer/src/Event/ComposeBatchEvent.php new file mode 100644 index 0000000000..17c4e9bc07 --- /dev/null +++ b/ext/flexmailer/src/Event/ComposeBatchEvent.php @@ -0,0 +1,55 @@ +getTasks() as $task) { + * $task->setMailParam('Subject', 'Hello'); + * $task->setMailParam('text', 'Hello there'); + * $task->setMailParam('html', '

Hello there

'); + * } + * ``` + */ +class ComposeBatchEvent extends BaseEvent { + + /** + * @var \Civi\FlexMailer\FlexMailerTask[] + */ + private $tasks; + + public function __construct($context, $tasks) { + parent::__construct($context); + $this->tasks = $tasks; + } + + /** + * @return \Civi\FlexMailer\FlexMailerTask[] + */ + public function getTasks() { + return $this->tasks; + } + + /** + * @return bool + */ + public function isPreview() { + return isset($this->context['is_preview']) + ? (bool) $this->context['is_preview'] + : FALSE; + } + +} diff --git a/ext/flexmailer/src/Event/RunEvent.php b/ext/flexmailer/src/Event/RunEvent.php new file mode 100644 index 0000000000..2960f4a8a1 --- /dev/null +++ b/ext/flexmailer/src/Event/RunEvent.php @@ -0,0 +1,48 @@ +stopPropagation()` + * and `$event->setCompleted($bool)`. + */ +class RunEvent extends BaseEvent { + + /** + * @var bool|null + */ + private $isCompleted = NULL; + + /** + * @return bool|NULL + */ + public function getCompleted() { + return $this->isCompleted; + } + + /** + * @param bool|NULL $isCompleted + * @return RunEvent + */ + public function setCompleted($isCompleted) { + $this->isCompleted = $isCompleted; + return $this; + } + +} diff --git a/ext/flexmailer/src/Event/SendBatchEvent.php b/ext/flexmailer/src/Event/SendBatchEvent.php new file mode 100644 index 0000000000..4ffeb884f3 --- /dev/null +++ b/ext/flexmailer/src/Event/SendBatchEvent.php @@ -0,0 +1,57 @@ +tasks = $tasks; + } + + /** + * @return array<\Civi\FlexMailer\FlexMailerTask> + */ + public function getTasks() { + return $this->tasks; + } + + /** + * @return bool|NULL + */ + public function getCompleted() { + return $this->isCompleted; + } + + /** + * @param bool|NULL $isCompleted + * @return SendBatchEvent + */ + public function setCompleted($isCompleted) { + $this->isCompleted = $isCompleted; + return $this; + } + +} diff --git a/ext/flexmailer/src/Event/WalkBatchesEvent.php b/ext/flexmailer/src/Event/WalkBatchesEvent.php new file mode 100644 index 0000000000..014aaf9534 --- /dev/null +++ b/ext/flexmailer/src/Event/WalkBatchesEvent.php @@ -0,0 +1,58 @@ +callback = $callback; + } + + /** + * @return bool|NULL + */ + public function getCompleted() { + return $this->isDelivered; + } + + /** + * @param bool|NULL $isCompleted + * @return WalkBatchesEvent + */ + public function setCompleted($isCompleted) { + $this->isDelivered = $isCompleted; + return $this; + } + + /** + * @param \Civi\FlexMailer\FlexMailerTask[] $tasks + * @return mixed + */ + public function visit($tasks) { + return call_user_func($this->callback, $tasks); + } + +} diff --git a/ext/flexmailer/src/FlexMailer.php b/ext/flexmailer/src/FlexMailer.php new file mode 100644 index 0000000000..c46160a738 --- /dev/null +++ b/ext/flexmailer/src/FlexMailer.php @@ -0,0 +1,226 @@ +setDefinition('mymod_subscriber', new Definition('MymodSubscriber', array())) + * ->addTag('kernel.event_subscriber'); + * } + * + * FlexMailer includes default listeners for all of these events. They + * behaves in basically the same way as CiviMail's traditional BAO-based + * delivery system (respecting mailerJobSize, mailThrottleTime, + * mailing_backend, hook_civicrm_alterMailParams, etal). However, you + * can replace any of the major functions, e.g. + * + * - If you send large blasts across multiple servers, then you may + * prefer a different algorithm for splitting the recipient list. + * Listen for WalkBatchesEvent. + * - If you want to compose messages in a new way (e.g. a different + * templating language), then listen for ComposeBatchEvent. + * - If you want to deliver messages through a different medium + * (such as web-services or batched SMTP), listen for SendBatchEvent. + * + * In all cases, your function can listen to the event and then decide what + * to do. If your listener does the work required for the event, then + * you can disable the default listener by calling `$event->stopPropagation()`. + * + * @link http://symfony.com/doc/current/components/event_dispatcher.html + */ +class FlexMailer { + + const WEIGHT_START = 2000; + const WEIGHT_PREPARE = 1000; + const WEIGHT_MAIN = 0; + const WEIGHT_ALTER = -1000; + const WEIGHT_END = -2000; + + const EVENT_RUN = 'civi.flexmailer.run'; + const EVENT_WALK = 'civi.flexmailer.walk'; + const EVENT_COMPOSE = 'civi.flexmailer.compose'; + const EVENT_SEND = 'civi.flexmailer.send'; + + /** + * @return array + * Array(string $event => string $class). + */ + public static function getEventTypes() { + return array( + self::EVENT_RUN => 'Civi\\FlexMailer\\Event\\RunEvent', + self::EVENT_WALK => 'Civi\\FlexMailer\\Event\\WalkBatchesEvent', + self::EVENT_COMPOSE => 'Civi\\FlexMailer\\Event\\ComposeBatchEvent', + self::EVENT_SEND => 'Civi\\FlexMailer\\Event\\SendBatchEvent', + ); + } + + /** + * @var array + * An array which must define options: + * - mailing: \CRM_Mailing_BAO_Mailing + * - job: \CRM_Mailing_BAO_MailingJob + * - attachments: array + * - is_preview: bool + * + * Additional options may be passed. To avoid naming conflicts, use prefixing. + */ + public $context; + + /** + * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface + */ + private $dispatcher; + + /** + * Create a new FlexMailer instance, using data available in the CiviMail runJobs(). + * + * @param \CRM_Mailing_BAO_MailingJob $job + * @param object $deprecatedMessageMailer + * @param array $deprecatedTestParams + * @return bool + * TRUE if delivery completed. + */ + public static function createAndRun($job, $deprecatedMessageMailer, $deprecatedTestParams) { + $flexMailer = new \Civi\FlexMailer\FlexMailer(array( + 'mailing' => \CRM_Mailing_BAO_Mailing::findById($job->mailing_id), + 'job' => $job, + 'attachments' => \CRM_Core_BAO_File::getEntityFile('civicrm_mailing', $job->mailing_id), + 'deprecatedMessageMailer' => $deprecatedMessageMailer, + 'deprecatedTestParams' => $deprecatedTestParams, + )); + return $flexMailer->run(); + } + + /** + * FlexMailer constructor. + * @param array $context + * An array which must define options: + * - mailing: \CRM_Mailing_BAO_Mailing + * - job: \CRM_Mailing_BAO_MailingJob + * - attachments: array + * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher + */ + public function __construct($context = array(), EventDispatcherInterface $dispatcher = NULL) { + $this->context = $context; + $this->dispatcher = $dispatcher ? $dispatcher : \Civi::service('dispatcher'); + } + + /** + * @return bool + * TRUE if delivery completed. + * @throws \CRM_Core_Exception + */ + public function run() { + // PHP 5.3 + $flexMailer = $this; + + if (count($this->validate()) > 0) { + throw new \CRM_Core_Exception("FlexMailer cannot execute: invalid context"); + } + + $run = $this->fireRun(); + if ($run->isPropagationStopped()) { + return $run->getCompleted(); + } + + $walkBatches = $this->fireWalkBatches(function ($tasks) use ($flexMailer) { + $flexMailer->fireComposeBatch($tasks); + $sendBatch = $flexMailer->fireSendBatch($tasks); + return $sendBatch->getCompleted(); + }); + + return $walkBatches->getCompleted(); + } + + /** + * @return array + * List of error messages + */ + public function validate() { + $errors = array(); + if (empty($this->context['mailing'])) { + $errors['mailing'] = 'Missing \"mailing\"'; + } + if (empty($this->context['job'])) { + $errors['job'] = 'Missing \"job\"'; + } + return $errors; + } + + /** + * @return \Civi\FlexMailer\Event\RunEvent + */ + public function fireRun() { + $event = new RunEvent($this->context); + $this->dispatcher->dispatch(self::EVENT_RUN, $event); + return $event; + } + + /** + * @param callable $onVisitBatch + * @return \Civi\FlexMailer\Event\WalkBatchesEvent + */ + public function fireWalkBatches($onVisitBatch) { + $event = new WalkBatchesEvent($this->context, $onVisitBatch); + $this->dispatcher->dispatch(self::EVENT_WALK, $event); + return $event; + } + + /** + * @param array $tasks + * @return \Civi\FlexMailer\Event\ComposeBatchEvent + */ + public function fireComposeBatch($tasks) { + // This isn't a great place for this, but it ensures consistent cleanup. + $mailing = $this->context['mailing']; + if (property_exists($mailing, 'language') && $mailing->language && $mailing->language != 'en_US') { + $swapLang = \CRM_Utils_AutoClean::swap('call://i18n/getLocale', 'call://i18n/setLocale', $mailing->language); + } + + $event = new ComposeBatchEvent($this->context, $tasks); + $this->dispatcher->dispatch(self::EVENT_COMPOSE, $event); + return $event; + } + + /** + * @param array $tasks + * @return \Civi\FlexMailer\Event\SendBatchEvent + */ + public function fireSendBatch($tasks) { + $event = new SendBatchEvent($this->context, $tasks); + $this->dispatcher->dispatch(self::EVENT_SEND, $event); + return $event; + } + +} diff --git a/ext/flexmailer/src/FlexMailerTask.php b/ext/flexmailer/src/FlexMailerTask.php new file mode 100644 index 0000000000..92f9a01a32 --- /dev/null +++ b/ext/flexmailer/src/FlexMailerTask.php @@ -0,0 +1,167 @@ +setMailParams(...)); + * - During delivery, we read the message ($task->getMailParams()) + * and send it. + */ +class FlexMailerTask { + + /** + * @var int + * A persistent record for this email delivery. + * @see \CRM_Mailing_Event_DAO_Queue + */ + private $eventQueueId; + + /** + * @var int + * The ID of the recipiient. + * @see \CRM_Contact_DAO_Contact + */ + private $contactId; + + /** + * @var string + * An authentication code. The name is misleading - it may be hash, but + * that implementation detail is outside our purview. + */ + private $hash; + + /** + * @var string + * Selected/preferred email address of the intended recipient. + */ + private $address; + + /** + * The full email message to send to this recipient (per alterMailParams). + * + * @var array + * @see MailParams + * @see \CRM_Utils_Hook::alterMailParams() + */ + private $mailParams = array(); + + /** + * FlexMailerTask constructor. + * + * @param int $eventQueueId + * A persistent record for this email delivery. + * @param int $contactId + * The ID of the recipiient. + * @param string $hash + * An authentication code. + * @param string $address + * Selected/preferred email address of the intended recipient. + */ + public function __construct( + $eventQueueId, + $contactId, + $hash, + $address + ) { + $this->eventQueueId = $eventQueueId; + $this->contactId = $contactId; + $this->hash = $hash; + $this->address = $address; + } + + /** + * @return int + * @see \CRM_Mailing_Event_DAO_Queue + */ + public function getEventQueueId() { + return $this->eventQueueId; + } + + /** + * @return int + * The ID of the recipiient. + * @see \CRM_Contact_DAO_Contact + */ + public function getContactId() { + return $this->contactId; + } + + /** + * @return string + * An authentication code. The name is misleading - it may be hash, but + * that implementation detail is outside our purview. + */ + public function getHash() { + return $this->hash; + } + + /** + * @return string + * Selected email address of the intended recipient. + */ + public function getAddress() { + return $this->address; + } + + /** + * @return bool + */ + public function hasContent() { + return !empty($this->mailParams['html']) || !empty($this->mailParams['text']); + } + + /** + * @return array + * @see CRM_Utils_Hook::alterMailParams + */ + public function getMailParams() { + return $this->mailParams; + } + + /** + * @param \array $mailParams + * @return FlexMailerTask + * @see CRM_Utils_Hook::alterMailParams + */ + public function setMailParams($mailParams) { + $this->mailParams = $mailParams; + return $this; + } + + /** + * @param string $key + * @param string $value + * @return $this + * @see CRM_Utils_Hook::alterMailParams + */ + public function setMailParam($key, $value) { + $this->mailParams[$key] = $value; + return $this; + } + + /** + * @param string $key + * @return string + * @see CRM_Utils_Hook::alterMailParams + */ + public function getMailParam($key) { + return isset($this->mailParams[$key]) ? $this->mailParams[$key] : NULL; + } + +} diff --git a/ext/flexmailer/src/Listener/Abdicator.php b/ext/flexmailer/src/Listener/Abdicator.php new file mode 100644 index 0000000000..5ec0077209 --- /dev/null +++ b/ext/flexmailer/src/Listener/Abdicator.php @@ -0,0 +1,100 @@ +sms_provider_id) { + return FALSE; + } + + // Use FlexMailer for new-style email blasts (with custom `template_type`). + if ($mailing->template_type && $mailing->template_type !== 'traditional') { + return TRUE; + } + + switch (\Civi::settings()->get('flexmailer_traditional')) { + case 'auto': + // Transitional support for old hidden setting "experimentalFlexMailerEngine" (bool) + // TODO: Remove this. Maybe after Q4 2019. + // TODO: Change this to default to flexmailer + return (bool) \Civi::settings()->get('experimentalFlexMailerEngine'); + + case 'bao': + return FALSE; + + case 'flexmailer': + return TRUE; + + default: + throw new \RuntimeException("Unrecognized value for setting 'flexmailer_traditional'"); + } + } + + /** + * Abdicate; defer to the old system during delivery. + * + * @param \Civi\FlexMailer\Event\RunEvent $e + */ + public function onRun(RunEvent $e) { + if (self::isFlexmailPreferred($e->getMailing())) { + // OK, we'll continue running. + return; + } + + // Nope, we'll abdicate. + $e->stopPropagation(); + $isDelivered = $e->getJob()->deliver( + $e->context['deprecatedMessageMailer'], + $e->context['deprecatedTestParams'] + ); + $e->setCompleted($isDelivered); + } + + /** + * Abdicate; defer to the old system when checking completeness. + * + * @param \Civi\FlexMailer\Event\CheckSendableEvent $e + */ + public function onCheckSendable($e) { + if (self::isFlexmailPreferred($e->getMailing())) { + // OK, we'll continue running. + return; + } + + $e->stopPropagation(); + $errors = \CRM_Mailing_BAO_Mailing::checkSendable($e->getMailing()); + if (is_array($errors)) { + foreach ($errors as $key => $message) { + $e->setError($key, $message);; + } + } + } + +} diff --git a/ext/flexmailer/src/Listener/Attachments.php b/ext/flexmailer/src/Listener/Attachments.php new file mode 100644 index 0000000000..6b387a7de5 --- /dev/null +++ b/ext/flexmailer/src/Listener/Attachments.php @@ -0,0 +1,33 @@ +isActive()) { + return; + } + + foreach ($e->getTasks() as $task) { + /** @var \Civi\FlexMailer\FlexMailerTask $task */ + $task->setMailParam('attachments', $e->getAttachments()); + } + } + +} diff --git a/ext/flexmailer/src/Listener/BaseListener.php b/ext/flexmailer/src/Listener/BaseListener.php new file mode 100644 index 0000000000..fb4a31b484 --- /dev/null +++ b/ext/flexmailer/src/Listener/BaseListener.php @@ -0,0 +1,33 @@ +active; + } + + /** + * @param bool $active + */ + public function setActive($active) { + $this->active = $active; + } + +} diff --git a/ext/flexmailer/src/Listener/BasicHeaders.php b/ext/flexmailer/src/Listener/BasicHeaders.php new file mode 100644 index 0000000000..025a165e4e --- /dev/null +++ b/ext/flexmailer/src/Listener/BasicHeaders.php @@ -0,0 +1,69 @@ +isActive()) { + return; + } + + $mailing = $e->getMailing(); + + foreach ($e->getTasks() as $task) { + /** @var \Civi\FlexMailer\FlexMailerTask $task */ + + if ($task->hasContent()) { + continue; + } + + list($verp) = $mailing->getVerpAndUrlsAndHeaders( + $e->getJob()->id, $task->getEventQueueId(), $task->getHash(), + $task->getAddress()); + + $mailParams = array(); + $mailParams['List-Unsubscribe'] = ""; + \CRM_Mailing_BAO_Mailing::addMessageIdHeader($mailParams, 'm', $e->getJob()->id, $task->getEventQueueId(), $task->getHash()); + $mailParams['Precedence'] = 'bulk'; + $mailParams['job_id'] = $e->getJob()->id; + + $mailParams['From'] = "\"{$mailing->from_name}\" <{$mailing->from_email}>"; + + // This old behavior for choosing Reply-To feels flawed to me -- if + // the user has chosen a Reply-To that matches the From, then it uses VERP?! + // $mailParams['Reply-To'] = $verp['reply']; + // if ($mailing->replyto_email && ($mailParams['From'] != $mailing->replyto_email)) { + // $mailParams['Reply-To'] = $mailing->replyto_email; + // } + + if (!$mailing->override_verp) { + $mailParams['Reply-To'] = $verp['reply']; + } + elseif ($mailing->replyto_email && ($mailParams['From'] != $mailing->replyto_email)) { + $mailParams['Reply-To'] = $mailing->replyto_email; + } + + $task->setMailParams(array_merge( + $mailParams, + $task->getMailParams() + )); + } + } + +} diff --git a/ext/flexmailer/src/Listener/BounceTracker.php b/ext/flexmailer/src/Listener/BounceTracker.php new file mode 100644 index 0000000000..210fea22df --- /dev/null +++ b/ext/flexmailer/src/Listener/BounceTracker.php @@ -0,0 +1,44 @@ +isActive()) { + return; + } + + $mailing = $e->getMailing(); + + foreach ($e->getTasks() as $task) { + /** @var \Civi\FlexMailer\FlexMailerTask $task */ + list($verp) = $mailing->getVerpAndUrlsAndHeaders( + $e->getJob()->id, $task->getEventQueueId(), $task->getHash(), + $task->getAddress()); + + if (!$task->getMailParam('Return-Path')) { + $task->setMailParam('Return-Path', $verp['bounce']); + } + if (!$task->getMailParam('X-CiviMail-Bounce')) { + $task->setMailParam('X-CiviMail-Bounce', $verp['bounce']); + } + } + } + +} diff --git a/ext/flexmailer/src/Listener/DefaultBatcher.php b/ext/flexmailer/src/Listener/DefaultBatcher.php new file mode 100644 index 0000000000..028f593c89 --- /dev/null +++ b/ext/flexmailer/src/Listener/DefaultBatcher.php @@ -0,0 +1,76 @@ +getJob()`), enumerate the recipients as + * a batch of FlexMailerTasks and visit each batch (`$e->visit($tasks)`). + * + * @param \Civi\FlexMailer\Event\WalkBatchesEvent $e + */ + public function onWalk(WalkBatchesEvent $e) { + if (!$this->isActive()) { + return; + } + + $e->stopPropagation(); + + $job = $e->getJob(); + + // CRM-12376 + // This handles the edge case scenario where all the mails + // have been delivered in prior jobs. + $isDelivered = TRUE; + + // make sure that there's no more than $mailerBatchLimit mails processed in a run + $mailerBatchLimit = \CRM_Core_Config::singleton()->mailerBatchLimit; + + $eq = \CRM_Mailing_BAO_MailingJob::findPendingTasks($job->id, 'email'); + $tasks = array(); + while ($eq->fetch()) { + if ($mailerBatchLimit > 0 && \CRM_Mailing_BAO_MailingJob::$mailsProcessed >= $mailerBatchLimit) { + if (!empty($tasks)) { + $e->visit($tasks); + } + $eq->free(); + $e->setCompleted(FALSE); + return; + } + \CRM_Mailing_BAO_MailingJob::$mailsProcessed++; + + // FIXME: To support SMS, the address should be $eq->phone instead of $eq->email + $tasks[] = new FlexMailerTask($eq->id, $eq->contact_id, $eq->hash, + $eq->email); + if (count($tasks) == \CRM_Mailing_BAO_MailingJob::MAX_CONTACTS_TO_PROCESS) { + $isDelivered = $e->visit($tasks); + if (!$isDelivered) { + $eq->free(); + $e->setCompleted($isDelivered); + return; + } + $tasks = array(); + } + } + + $eq->free(); + + if (!empty($tasks)) { + $isDelivered = $e->visit($tasks); + } + $e->setCompleted($isDelivered); + } + +} diff --git a/ext/flexmailer/src/Listener/DefaultComposer.php b/ext/flexmailer/src/Listener/DefaultComposer.php new file mode 100644 index 0000000000..d9c61df4a2 --- /dev/null +++ b/ext/flexmailer/src/Listener/DefaultComposer.php @@ -0,0 +1,216 @@ +isActive() || !$this->isSupported($e->getMailing())) { + return; + } + + $tp = new TokenProcessor(\Civi::service('dispatcher'), + $this->createTokenProcessorContext($e)); + + $tpls = $this->createMessageTemplates($e); + $tp->addMessage('subject', $tpls['subject'], 'text/plain'); + $tp->addMessage('body_text', isset($tpls['text']) ? $tpls['text'] : '', + 'text/plain'); + $tp->addMessage('body_html', isset($tpls['html']) ? $tpls['html'] : '', + 'text/html'); + + $hasContent = FALSE; + foreach ($e->getTasks() as $key => $task) { + /** @var \Civi\FlexMailer\FlexMailerTask $task */ + if (!$task->hasContent()) { + $tp->addRow()->context($this->createTokenRowContext($e, $task)); + $hasContent = TRUE; + } + } + + if (!$hasContent) { + return; + } + + $tp->evaluate(); + + foreach ($tp->getRows() as $row) { + /** @var \Civi\Token\TokenRow $row */ + /** @var \Civi\FlexMailer\FlexMailerTask $task */ + $task = $row->context['flexMailerTask']; + $task->setMailParams(array_merge( + $this->createMailParams($e, $task, $row), + $task->getMailParams() + )); + } + } + + /** + * Define the contextual parameters for the token-processor. + * + * @param \Civi\FlexMailer\Event\ComposeBatchEvent $e + * @return array + */ + public function createTokenProcessorContext(ComposeBatchEvent $e) { + $context = array( + 'controller' => get_class($this), + // FIXME: Use template_type, template_options + 'smarty' => defined('CIVICRM_MAIL_SMARTY') && CIVICRM_MAIL_SMARTY ? TRUE : FALSE, + 'mailing' => $e->getMailing(), + 'mailingId' => $e->getMailing()->id, + ); + return $context; + } + + /** + * Create contextual data for a message recipient. + * + * @param \Civi\FlexMailer\Event\ComposeBatchEvent $e + * @param \Civi\FlexMailer\FlexMailerTask $task + * @return array + * Contextual data describing the recipient. + * Typical values are `contactId` or `mailingJobId`. + */ + public function createTokenRowContext( + ComposeBatchEvent $e, + FlexMailerTask $task + ) { + return array( + 'contactId' => $task->getContactId(), + 'mailingJobId' => $e->getJob()->id, + 'mailingActionTarget' => array( + 'id' => $task->getEventQueueId(), + 'hash' => $task->getHash(), + 'email' => $task->getAddress(), + ), + 'flexMailerTask' => $task, + ); + } + + /** + * For a given task, prepare the mailing. + * + * @param \Civi\FlexMailer\Event\ComposeBatchEvent $e + * @param \Civi\FlexMailer\FlexMailerTask $task + * @param \Civi\Token\TokenRow $row + * @return array + * A list of email parameters, such as "Subject", "text", and/or "html". + * @see \CRM_Utils_Hook::alterMailParams + */ + public function createMailParams( + ComposeBatchEvent $e, + FlexMailerTask $task, + TokenRow $row + ) { + return array( + 'Subject' => $row->render('subject'), + 'text' => $row->render('body_text'), + 'html' => $row->render('body_html'), + ); + } + + /** + * Generate the message templates for use with token-processor. + * + * @param \Civi\FlexMailer\Event\ComposeBatchEvent $e + * @return array + * A list of templates. Some combination of: + * - subject: string + * - html: string + * - text: string + */ + public function createMessageTemplates(ComposeBatchEvent $e) { + $templates = $e->getMailing()->getTemplates(); + if ($this->isClickTracking($e)) { + $templates = $this->applyClickTracking($e, $templates); + } + return $templates; + } + + /** + * (Tentative) Alter hyperlinks to perform click-tracking. + * + * This functionality probably belongs somewhere else. The + * current placement feels quirky, and it's hard to inspect + * via `cv debug:event-dispatcher', but it produces the expected + * interactions among tokens and click-tracking. + * + * @param \Civi\FlexMailer\Event\ComposeBatchEvent $e + * @param array $templates + * @return array + * Updated templates. + */ + protected function applyClickTracking(ComposeBatchEvent $e, $templates) { + $mailing = $e->getMailing(); + + if (!empty($templates['html'])) { + $templates['html'] = \Civi::service('civi_flexmailer_html_click_tracker') + ->filterContent($templates['html'], $mailing->id, + '{action.eventQueueId}'); + } + if (!empty($templates['text'])) { + $templates['text'] = \Civi::service('civi_flexmailer_text_click_tracker') + ->filterContent($templates['text'], $mailing->id, + '{action.eventQueueId}'); + } + + return $templates; + } + + /** + * Determine whether to enable click-tracking. + * + * @param \Civi\FlexMailer\Event\ComposeBatchEvent $e + * @return bool + */ + public function isClickTracking(ComposeBatchEvent $e) { + // Don't track clicks on previews. Doing so would accumulate a lot + // of garbage data. + return $e->getMailing()->url_tracking && !$e->isPreview(); + } + +} diff --git a/ext/flexmailer/src/Listener/DefaultSender.php b/ext/flexmailer/src/Listener/DefaultSender.php new file mode 100644 index 0000000000..fc07daff2c --- /dev/null +++ b/ext/flexmailer/src/Listener/DefaultSender.php @@ -0,0 +1,202 @@ +isActive()) { + return; + } + + $e->stopPropagation(); + + $job = $e->getJob(); + $mailing = $e->getMailing(); + $job_date = \CRM_Utils_Date::isoToMysql($job->scheduled_date); + $mailer = \Civi::service('pear_mail'); + + $targetParams = $deliveredParams = array(); + $count = 0; + $retryBatch = FALSE; + + foreach ($e->getTasks() as $key => $task) { + /** @var \Civi\FlexMailer\FlexMailerTask $task */ + /** @var \Mail_mime $message */ + if (!$task->hasContent()) { + continue; + } + + $message = \Civi\FlexMailer\MailParams::convertMailParamsToMime($task->getMailParams()); + + if (empty($message)) { + // lets keep the message in the queue + // most likely a permissions related issue with smarty templates + // or a bad contact id? CRM-9833 + continue; + } + + // disable error reporting on real mailings (but leave error reporting for tests), CRM-5744 + if ($job_date) { + $errorScope = \CRM_Core_TemporaryErrorScope::ignoreException(); + } + + $headers = $message->headers(); + $result = $mailer->send($headers['To'], $message->headers(), $message->get()); + + if ($job_date) { + unset($errorScope); + } + + if (is_a($result, 'PEAR_Error')) { + /** @var \PEAR_Error $result */ + // CRM-9191 + $message = $result->getMessage(); + if ($this->isTemporaryError($result->getMessage())) { + // lets log this message and code + $code = $result->getCode(); + \CRM_Core_Error::debug_log_message("SMTP Socket Error or failed to set sender error. Message: $message, Code: $code"); + + // these are socket write errors which most likely means smtp connection errors + // lets skip them and reconnect. + $smtpConnectionErrors++; + if ($smtpConnectionErrors <= 5) { + $mailer->disconnect(); + $retryBatch = TRUE; + continue; + } + + // seems like we have too many of them in a row, we should + // write stuff to disk and abort the cron job + $job->writeToDB($deliveredParams, $targetParams, $mailing, $job_date); + + \CRM_Core_Error::debug_log_message("Too many SMTP Socket Errors. Exiting"); + \CRM_Utils_System::civiExit(); + } + else { + $this->recordBounce($job, $task, $result->getMessage()); + } + } + else { + // Register the delivery event. + $deliveredParams[] = $task->getEventQueueId(); + $targetParams[] = $task->getContactId(); + + $count++; + if ($count % self::BULK_MAIL_INSERT_COUNT == 0) { + $job->writeToDB($deliveredParams, $targetParams, $mailing, $job_date); + $count = 0; + + // hack to stop mailing job at run time, CRM-4246. + // to avoid making too many DB calls for this rare case + // lets do it when we snapshot + $status = \CRM_Core_DAO::getFieldValue( + 'CRM_Mailing_DAO_MailingJob', + $job->id, + 'status', + 'id', + TRUE + ); + + if ($status != 'Running') { + $e->setCompleted(FALSE); + return; + } + } + } + + unset($result); + + // seems like a successful delivery or bounce, lets decrement error count + // only if we have smtp connection errors + if ($smtpConnectionErrors > 0) { + $smtpConnectionErrors--; + } + + // If we have enabled the Throttle option, this is the time to enforce it. + $mailThrottleTime = \CRM_Core_Config::singleton()->mailThrottleTime; + if (!empty($mailThrottleTime)) { + usleep((int) $mailThrottleTime); + } + } + + $completed = $job->writeToDB( + $deliveredParams, + $targetParams, + $mailing, + $job_date + ); + if ($retryBatch) { + $completed = FALSE; + } + $e->setCompleted($completed); + } + + /** + * Determine if an SMTP error is temporary or permanent. + * + * @param string $message + * PEAR error message. + * @return bool + * TRUE - Temporary/retriable error + * FALSE - Permanent/non-retriable error + */ + protected function isTemporaryError($message) { + // SMTP response code is buried in the message. + $code = preg_match('/ \(code: (.+), response: /', $message, $matches) ? $matches[1] : ''; + + if (strpos($message, 'Failed to write to socket') !== FALSE) { + return TRUE; + } + + // Register 5xx SMTP response code (permanent failure) as bounce. + if (isset($code{0}) && $code{0} === '5') { + return FALSE; + } + + if (strpos($message, 'Failed to set sender') !== FALSE) { + return TRUE; + } + + if (strpos($message, 'Failed to add recipient') !== FALSE) { + return TRUE; + } + + if (strpos($message, 'Failed to send data') !== FALSE) { + return TRUE; + } + + return FALSE; + } + + /** + * @param \CRM_Mailing_BAO_MailingJob $job + * @param \Civi\FlexMailer\FlexMailerTask $task + * @param string $errorMessage + */ + protected function recordBounce($job, $task, $errorMessage) { + $params = array( + 'event_queue_id' => $task->getEventQueueId(), + 'job_id' => $job->id, + 'hash' => $task->getHash(), + ); + $params = array_merge($params, + \CRM_Mailing_BAO_BouncePattern::match($errorMessage) + ); + \CRM_Mailing_Event_BAO_Bounce::create($params); + } + +} diff --git a/ext/flexmailer/src/Listener/HookAdapter.php b/ext/flexmailer/src/Listener/HookAdapter.php new file mode 100644 index 0000000000..53d3aa4f8b --- /dev/null +++ b/ext/flexmailer/src/Listener/HookAdapter.php @@ -0,0 +1,37 @@ +isActive()) { + return; + } + + foreach ($e->getTasks() as $task) { + /** @var \Civi\FlexMailer\FlexMailerTask $task */ + $mailParams = $task->getMailParams(); + if ($mailParams) { + \CRM_Utils_Hook::alterMailParams($mailParams, 'flexmailer'); + $task->setMailParams($mailParams); + } + } + } + +} diff --git a/ext/flexmailer/src/Listener/OpenTracker.php b/ext/flexmailer/src/Listener/OpenTracker.php new file mode 100644 index 0000000000..1e0dc84609 --- /dev/null +++ b/ext/flexmailer/src/Listener/OpenTracker.php @@ -0,0 +1,48 @@ +isActive() || !$e->getMailing()->open_tracking) { + return; + } + + $config = \CRM_Core_Config::singleton(); + + // TODO: After v5.21 goes EOL, remove the $isLegacy check. + $isLegacy = version_compare(\CRM_Utils_System::version(), '5.23.alpha', '<'); + + foreach ($e->getTasks() as $task) { + /** @var \Civi\FlexMailer\FlexMailerTask $task */ + $mailParams = $task->getMailParams(); + + if (!empty($mailParams) && !empty($mailParams['html'])) { + $openUrl = $isLegacy + ? $config->userFrameworkResourceURL . "extern/open.php?q=" . $task->getEventQueueId() + : \CRM_Utils_System::externUrl('extern/open', "q=" . $task->getEventQueueId()); + + $mailParams['html'] .= "\n" . '"; + + $task->setMailParams($mailParams); + } + } + } + +} diff --git a/ext/flexmailer/src/Listener/RequiredFields.php b/ext/flexmailer/src/Listener/RequiredFields.php new file mode 100644 index 0000000000..087d5d79dd --- /dev/null +++ b/ext/flexmailer/src/Listener/RequiredFields.php @@ -0,0 +1,109 @@ +fields = $fields; + } + + /** + * Check for required fields. + * + * @param \Civi\FlexMailer\Event\CheckSendableEvent $e + */ + public function onCheckSendable(CheckSendableEvent $e) { + if (!$this->isActive()) { + return; + } + + foreach ($this->fields as $field) { + // Parentheses indicate multiple options. Ex: '(body_html|body_text)' + if ($field{0} === '(') { + $alternatives = explode('|', substr($field, 1, -1)); + $fieldTitle = implode(' or ', array_map(function ($x) { + return "\"$x\""; + }, $alternatives)); + $found = $this->hasAny($e->getMailing(), $alternatives); + } + else { + $fieldTitle = "\"$field\""; + $found = !empty($e->getMailing()->{$field}); + } + + if (!$found) { + $e->setError($field, E::ts('Field %1 is required.', array( + 1 => $fieldTitle, + ))); + } + unset($found); + } + } + + /** + * Determine if $object has any of the given properties. + * + * @param mixed $object + * @param array $alternatives + * @return bool + */ + protected function hasAny($object, $alternatives) { + foreach ($alternatives as $alternative) { + if (!empty($object->{$alternative})) { + return TRUE; + } + } + return FALSE; + } + + /** + * Get the list of required fields. + * + * @return array + * Ex: array('subject', 'from_name', '(body_html|body_text)'). + */ + public function getFields() { + return $this->fields; + } + + /** + * Set the list of required fields. + * + * @param array $fields + * Ex: array('subject', 'from_name', '(body_html|body_text)'). + * @return RequiredFields + */ + public function setFields($fields) { + $this->fields = $fields; + return $this; + } + +} diff --git a/ext/flexmailer/src/Listener/RequiredTokens.php b/ext/flexmailer/src/Listener/RequiredTokens.php new file mode 100644 index 0000000000..e627243c97 --- /dev/null +++ b/ext/flexmailer/src/Listener/RequiredTokens.php @@ -0,0 +1,157 @@ + ts('The organizational postal address')) + */ + private $requiredTokens; + + /** + * @var array + * + * List of template-types for which we are capable of enforcing token + * requirements. + */ + private $templateTypes; + + /** + * RequiredTokens constructor. + * + * @param array $templateTypes + * Ex: array('traditional'). + * @param array $requiredTokens + * Ex: array('domain.address' => ts('The organizational postal address')) + */ + public function __construct($templateTypes, $requiredTokens) { + $this->templateTypes = $templateTypes; + $this->requiredTokens = $requiredTokens; + } + + /** + * Check for required fields. + * + * @param \Civi\FlexMailer\Event\CheckSendableEvent $e + */ + public function onCheckSendable(CheckSendableEvent $e) { + if (!$this->isActive()) { + return; + } + if (\Civi::settings()->get('disable_mandatory_tokens_check')) { + return; + } + if (!in_array($e->getMailing()->template_type, $this->getTemplateTypes())) { + return; + } + + foreach (array('body_html', 'body_text') as $field) { + $str = $e->getFullBody($field); + if (empty($str)) { + continue; + } + foreach ($this->findMissingTokens($str) as $token => $desc) { + $e->setError("{$field}:{$token}", E::ts('This message is missing a required token - {%1}: %2', + array(1 => $token, 2 => $desc) + )); + } + } + } + + public function findMissingTokens($str) { + $missing = array(); + foreach ($this->getRequiredTokens() as $token => $value) { + if (!is_array($value)) { + if (!preg_match('/(^|[^\{])' . preg_quote('{' . $token . '}') . '/', $str)) { + $missing[$token] = $value; + } + } + else { + $present = FALSE; + $desc = NULL; + foreach ($value as $t => $d) { + $desc = $d; + if (preg_match('/(^|[^\{])' . preg_quote('{' . $t . '}') . '/', $str)) { + $present = TRUE; + } + } + if (!$present) { + $missing[$token] = $desc; + } + } + } + return $missing; + } + + /** + * @return array + * Ex: array('domain.address' => ts('The organizational postal address')) + */ + public function getRequiredTokens() { + return $this->requiredTokens; + } + + /** + * @param array $requiredTokens + * Ex: array('domain.address' => ts('The organizational postal address')) + * @return RequiredTokens + */ + public function setRequiredTokens($requiredTokens) { + $this->requiredTokens = $requiredTokens; + return $this; + } + + /** + * @return array + * Ex: array('traditional'). + */ + public function getTemplateTypes() { + return $this->templateTypes; + } + + /** + * Set the list of template-types for which we check tokens. + * + * @param array $templateTypes + * Ex: array('traditional'). + * @return RequiredTokens + */ + public function setTemplateTypes($templateTypes) { + $this->templateTypes = $templateTypes; + return $this; + } + + /** + * Add to the list of template-types for which we check tokens. + * + * @param array $templateTypes + * Ex: array('traditional'). + * @return RequiredTokens + */ + public function addTemplateTypes($templateTypes) { + $this->templateTypes = array_unique(array_merge($this->templateTypes, $templateTypes)); + return $this; + } + +} diff --git a/ext/flexmailer/src/Listener/SimpleFilter.php b/ext/flexmailer/src/Listener/SimpleFilter.php new file mode 100644 index 0000000000..4d0f1a5571 --- /dev/null +++ b/ext/flexmailer/src/Listener/SimpleFilter.php @@ -0,0 +1,85 @@ +getTasks() as $task) { + /** @var \Civi\FlexMailer\FlexMailerTask $task */ + $value = $task->getMailParam($field); + if ($value !== NULL) { + $task->setMailParam($field, call_user_func($filter, $value, $task, $e)); + } + } + } + + /** + * Apply a filter function to a property of all email messages. + * + * This variant visits the values as a big array. This makes it + * amenable to batch-mode filtering in preg_replace or preg_replace_callback. + * + * @param \Civi\FlexMailer\Event\ComposeBatchEvent $e + * @param string $field + * The name of a MailParam field. + * @param mixed $filter + * Function($values, ComposeBatchEvent $e). + * Return a modified list of values. + * @throws \CRM_Core_Exception + * @see \CRM_Utils_Hook::alterMailParams + */ + public static function byColumn(ComposeBatchEvent $e, $field, $filter) { + $tasks = $e->getTasks(); + $values = array(); + + foreach ($tasks as $k => $task) { + /** @var \Civi\FlexMailer\FlexMailerTask $task */ + $value = $task->getMailParam($field); + if ($value !== NULL) { + $values[$k] = $value; + } + } + + $values = call_user_func_array($filter, array($values, $e)); + + foreach ($values as $k => $value) { + $tasks[$k]->setMailParam($field, $value); + } + } + +} diff --git a/ext/flexmailer/src/Listener/TestPrefix.php b/ext/flexmailer/src/Listener/TestPrefix.php new file mode 100644 index 0000000000..b81bb03852 --- /dev/null +++ b/ext/flexmailer/src/Listener/TestPrefix.php @@ -0,0 +1,35 @@ +isActive() || !$e->getJob()->is_test) { + return; + } + + foreach ($e->getTasks() as $task) { + /** @var \Civi\FlexMailer\FlexMailerTask $task */ + $subject = $task->getMailParam('Subject'); + $subject = ts('[CiviMail Draft]') . ' ' . $subject; + $task->setMailParam('Subject', $subject); + } + } + +} diff --git a/ext/flexmailer/src/Listener/ToHeader.php b/ext/flexmailer/src/Listener/ToHeader.php new file mode 100644 index 0000000000..a85a36139b --- /dev/null +++ b/ext/flexmailer/src/Listener/ToHeader.php @@ -0,0 +1,72 @@ +isActive()) { + return; + } + + $names = $this->getContactNames($e->getTasks()); + foreach ($e->getTasks() as $task) { + /** @var \Civi\FlexMailer\FlexMailerTask $task */ + + $task->setMailParam('toEmail', $task->getAddress()); + + if (isset($names[$task->getContactId()])) { + $task->setMailParam('toName', $names[$task->getContactId()]); + } + else { + $task->setMailParam('toName', ''); + } + } + } + + /** + * Lookup contact names as a batch. + * + * @param array $tasks + * @return array + * Array(int $contactId => string $displayName). + */ + protected function getContactNames($tasks) { + $ids = array(); + foreach ($tasks as $task) { + /** @var \Civi\FlexMailer\FlexMailerTask $task */ + $ids[$task->getContactId()] = $task->getContactId(); + } + + $ids = array_filter($ids, 'is_numeric'); + if (empty($ids)) { + return array(); + } + + $idString = implode(',', $ids); + + $query = \CRM_Core_DAO::executeQuery( + "SELECT id, display_name FROM civicrm_contact WHERE id in ($idString)"); + $names = array(); + while ($query->fetch()) { + $names[$query->id] = $query->display_name; + } + return $names; + } + +} diff --git a/ext/flexmailer/src/MailParams.php b/ext/flexmailer/src/MailParams.php new file mode 100644 index 0000000000..b7880e32d7 --- /dev/null +++ b/ext/flexmailer/src/MailParams.php @@ -0,0 +1,101 @@ +"; + + // 2. Apply the other fields. + foreach ($mailParams as $key => $value) { + if (empty($value)) { + continue; + } + + switch ($key) { + case 'text': + $message->setTxtBody($mailParams['text']); + break; + + case 'html': + $message->setHTMLBody($mailParams['html']); + break; + + case 'attachments': + foreach ($mailParams['attachments'] as $fileID => $attach) { + $message->addAttachment($attach['fullPath'], + $attach['mime_type'], + $attach['cleanName'] + ); + } + break; + + case 'headers': + $message->headers($value); + break; + + default: + $message->headers(array($key => $value), TRUE); + } + } + + \CRM_Utils_Mail::setMimeParams($message); + + return $message; + } + +} diff --git a/ext/flexmailer/src/Services.php b/ext/flexmailer/src/Services.php new file mode 100644 index 0000000000..9be823e167 --- /dev/null +++ b/ext/flexmailer/src/Services.php @@ -0,0 +1,143 @@ +addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__)); + + $apiOverrides = $container->setDefinition('civi_flexmailer_api_overrides', new Definition('Civi\API\Provider\ProviderInterface'))->setPublic(TRUE); + self::applyStaticFactory($apiOverrides, __CLASS__, 'createApiOverrides'); + + $container->setDefinition('civi_flexmailer_required_fields', new Definition('Civi\FlexMailer\Listener\RequiredFields', array( + array( + 'subject', + 'name', + 'from_name', + 'from_email', + '(body_html|body_text)', + ), + )))->setPublic(TRUE); + $container->setDefinition('civi_flexmailer_required_tokens', new Definition('Civi\FlexMailer\Listener\RequiredTokens', array( + array('traditional'), + array( + 'domain.address' => ts("Domain address - displays your organization's postal address."), + 'action.optOutUrl or action.unsubscribeUrl' => array( + 'action.optOut' => ts("'Opt out via email' - displays an email address for recipients to opt out of receiving emails from your organization."), + 'action.optOutUrl' => ts("'Opt out via web page' - creates a link for recipients to click if they want to opt out of receiving emails from your organization. Alternatively, you can include the 'Opt out via email' token."), + 'action.unsubscribe' => ts("'Unsubscribe via email' - displays an email address for recipients to unsubscribe from the specific mailing list used to send this message."), + 'action.unsubscribeUrl' => ts("'Unsubscribe via web page' - creates a link for recipients to unsubscribe from the specific mailing list used to send this message. Alternatively, you can include the 'Unsubscribe via email' token or one of the Opt-out tokens."), + ), + ), + )))->setPublic(TRUE); + + $container->setDefinition('civi_flexmailer_abdicator', new Definition('Civi\FlexMailer\Listener\Abdicator'))->setPublic(TRUE); + $container->setDefinition('civi_flexmailer_default_batcher', new Definition('Civi\FlexMailer\Listener\DefaultBatcher'))->setPublic(TRUE); + $container->setDefinition('civi_flexmailer_default_composer', new Definition('Civi\FlexMailer\Listener\DefaultComposer'))->setPublic(TRUE); + $container->setDefinition('civi_flexmailer_open_tracker', new Definition('Civi\FlexMailer\Listener\OpenTracker'))->setPublic(TRUE); + $container->setDefinition('civi_flexmailer_basic_headers', new Definition('Civi\FlexMailer\Listener\BasicHeaders'))->setPublic(TRUE); + $container->setDefinition('civi_flexmailer_to_header', new Definition('Civi\FlexMailer\Listener\ToHeader'))->setPublic(TRUE); + $container->setDefinition('civi_flexmailer_attachments', new Definition('Civi\FlexMailer\Listener\Attachments'))->setPublic(TRUE); + $container->setDefinition('civi_flexmailer_bounce_tracker', new Definition('Civi\FlexMailer\Listener\BounceTracker'))->setPublic(TRUE); + $container->setDefinition('civi_flexmailer_default_sender', new Definition('Civi\FlexMailer\Listener\DefaultSender'))->setPublic(TRUE); + $container->setDefinition('civi_flexmailer_hooks', new Definition('Civi\FlexMailer\Listener\HookAdapter'))->setPublic(TRUE); + $container->setDefinition('civi_flexmailer_test_prefix', new Definition('Civi\FlexMailer\Listener\TestPrefix'))->setPublic(TRUE); + + $container->setDefinition('civi_flexmailer_html_click_tracker', new Definition('Civi\FlexMailer\ClickTracker\HtmlClickTracker'))->setPublic(TRUE); + $container->setDefinition('civi_flexmailer_text_click_tracker', new Definition('Civi\FlexMailer\ClickTracker\TextClickTracker'))->setPublic(TRUE); + + foreach (self::getListenerSpecs() as $listenerSpec) { + $container->findDefinition('dispatcher')->addMethodCall('addListenerService', $listenerSpec); + } + + $container->findDefinition('civi_api_kernel')->addMethodCall('registerApiProvider', array(new Reference('civi_flexmailer_api_overrides'))); + } + + /** + * Get a list of listeners required for FlexMailer. + * + * This is a standalone, private function because we're experimenting + * with how exactly to handle the registration -- e.g. via + * `registerServices()` or via `registerListeners()`. + * + * @return array + * Arguments to pass to addListenerService($eventName, $callbackSvc, $priority). + */ + protected static function getListenerSpecs() { + $listenerSpecs = array(); + + $listenerSpecs[] = array(Validator::EVENT_CHECK_SENDABLE, array('civi_flexmailer_abdicator', 'onCheckSendable'), FM::WEIGHT_START); + $listenerSpecs[] = array(Validator::EVENT_CHECK_SENDABLE, array('civi_flexmailer_required_fields', 'onCheckSendable'), FM::WEIGHT_MAIN); + $listenerSpecs[] = array(Validator::EVENT_CHECK_SENDABLE, array('civi_flexmailer_required_tokens', 'onCheckSendable'), FM::WEIGHT_MAIN); + + $listenerSpecs[] = array(FM::EVENT_RUN, array('civi_flexmailer_default_composer', 'onRun'), FM::WEIGHT_MAIN); + $listenerSpecs[] = array(FM::EVENT_RUN, array('civi_flexmailer_abdicator', 'onRun'), FM::WEIGHT_END); + + $listenerSpecs[] = array(FM::EVENT_WALK, array('civi_flexmailer_default_batcher', 'onWalk'), FM::WEIGHT_END); + + $listenerSpecs[] = array(FM::EVENT_COMPOSE, array('civi_flexmailer_basic_headers', 'onCompose'), FM::WEIGHT_PREPARE); + $listenerSpecs[] = array(FM::EVENT_COMPOSE, array('civi_flexmailer_to_header', 'onCompose'), FM::WEIGHT_PREPARE); + $listenerSpecs[] = array(FM::EVENT_COMPOSE, array('civi_flexmailer_bounce_tracker', 'onCompose'), FM::WEIGHT_PREPARE); + $listenerSpecs[] = array(FM::EVENT_COMPOSE, array('civi_flexmailer_default_composer', 'onCompose'), FM::WEIGHT_MAIN - 100); + $listenerSpecs[] = array(FM::EVENT_COMPOSE, array('civi_flexmailer_attachments', 'onCompose'), FM::WEIGHT_ALTER); + $listenerSpecs[] = array(FM::EVENT_COMPOSE, array('civi_flexmailer_open_tracker', 'onCompose'), FM::WEIGHT_ALTER); + $listenerSpecs[] = array(FM::EVENT_COMPOSE, array('civi_flexmailer_test_prefix', 'onCompose'), FM::WEIGHT_ALTER); + $listenerSpecs[] = array(FM::EVENT_COMPOSE, array('civi_flexmailer_hooks', 'onCompose'), FM::WEIGHT_ALTER - 100); + + $listenerSpecs[] = array(FM::EVENT_SEND, array('civi_flexmailer_default_sender', 'onSend'), FM::WEIGHT_END); + + return $listenerSpecs; + } + + /** + * Tap into the API kernel and override some of the core APIs. + * + * @return \Civi\API\Provider\AdhocProvider + */ + public static function createApiOverrides() { + $provider = new \Civi\API\Provider\AdhocProvider(3, 'Mailing'); + // FIXME: stay in sync with upstream perms + $provider->addAction('preview', 'access CiviMail', '\Civi\FlexMailer\API\MailingPreview::preview'); + return $provider; + } + + /** + * Adapter for using factory methods in old+new versions of Symfony. + * + * @param \Symfony\Component\DependencyInjection\Definition $def + * @param string $factoryClass + * @param string $factoryMethod + * @return \Symfony\Component\DependencyInjection\Definition + * @deprecated + */ + protected static function applyStaticFactory($def, $factoryClass, $factoryMethod) { + if (method_exists($def, 'setFactory')) { + $def->setFactory(array($factoryClass, $factoryMethod)); + } + else { + $def->setFactoryClass($factoryClass)->setFactoryMethod($factoryMethod); + } + return $def; + } + +} diff --git a/ext/flexmailer/src/Validator.php b/ext/flexmailer/src/Validator.php new file mode 100644 index 0000000000..f0818634d3 --- /dev/null +++ b/ext/flexmailer/src/Validator.php @@ -0,0 +1,70 @@ +run(array( + 'mailing' => $mailing, + 'attachments' => \CRM_Core_BAO_File::getEntityFile('civicrm_mailing', $mailing->id), + )); + } + + /** + * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface + */ + private $dispatcher; + + /** + * FlexMailer constructor. + * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher + */ + public function __construct(EventDispatcherInterface $dispatcher = NULL) { + $this->dispatcher = $dispatcher ? $dispatcher : \Civi::service('dispatcher'); + } + + /** + * @param array $context + * An array which must define options: + * - mailing: \CRM_Mailing_BAO_Mailing + * - attachments: array + * @return array + * List of error messages. + * Ex: array('subject' => 'The Subject field is blank'). + * Example keys: 'subject', 'name', 'from_name', 'from_email', 'body', 'body_html:unsubscribeUrl'. + */ + public function run($context) { + $checkSendable = new CheckSendableEvent($context); + $this->dispatcher->dispatch(static::EVENT_CHECK_SENDABLE, $checkSendable); + return $checkSendable->getErrors(); + } + +} diff --git a/ext/flexmailer/tests/phpunit/Civi/FlexMailer/ClickTracker/HtmlClickTrackerTest.php b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/ClickTracker/HtmlClickTrackerTest.php new file mode 100644 index 0000000000..a486daed2e --- /dev/null +++ b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/ClickTracker/HtmlClickTrackerTest.php @@ -0,0 +1,90 @@ +getManager(); + if ($manager->getStatus('org.civicrm.flexmailer') !== \CRM_Extension_Manager::STATUS_INSTALLED) { + $manager->install(array('org.civicrm.flexmailer')); + } + + parent::setUp(); + \Civi::settings()->set('flexmailer_traditional', 'flexmailer'); + } + + public function getHrefExamples() { + $exs = []; + + // For each example, the test-harness will useHtmlClickTracker to wrap the URL in "tracking(...)". + + $exs[] = [ + // Basic case + '

Foo

', + '

Foo

', + ]; + $exs[] = [ + // Messy looking URL + '

Foo

', + '

Foo

', + ]; + $exs[] = [ + // Messy looking URL, designed to trip-up quote handling + '

Foo

', + '

Foo

', + ]; + $exs[] = [ + // Messy looking URL, designed to trip-up quote handling + '

Foo

', + '

Foo

', + ]; + $exs[] = [ + // Messy looking URL, funny whitespace + '

Foo

', + '

Foo

', + ]; + $exs[] = [ + // Messy looking URL, funny whitespace + '

Foo

', + '

Foo

', + ]; + $exs[] = [ + // Many different URLs + '

FirstSecondThirdFourth

', + '

FirstSecondThirdFourth

', + ]; + + return $exs; + } + + /** + * @param $inputHtml + * @param $expectHtml + * @dataProvider getHrefExamples + */ + public function testReplaceHref($inputHtml, $expectHtml) { + $actual = HtmlClickTracker::replaceHrefUrls($inputHtml, function($url) { + return "tracking($url)"; + }); + + $this->assertEquals($expectHtml, $actual, "Check substitutions on text ($inputHtml)"); + } + +} diff --git a/ext/flexmailer/tests/phpunit/Civi/FlexMailer/ClickTracker/TextClickTrackerTest.php b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/ClickTracker/TextClickTrackerTest.php new file mode 100644 index 0000000000..a99ac5473e --- /dev/null +++ b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/ClickTracker/TextClickTrackerTest.php @@ -0,0 +1,93 @@ +getManager(); + if ($manager->getStatus('org.civicrm.flexmailer') !== \CRM_Extension_Manager::STATUS_INSTALLED) { + $manager->install(array('org.civicrm.flexmailer')); + } + + parent::setUp(); + \Civi::settings()->set('flexmailer_traditional', 'flexmailer'); + } + + public function getHrefExamples() { + $exs = []; + + // For each example, the test-harness will useHtmlClickTracker to wrap the URL in "tracking(...)". + + $exs[] = [ + // Basic case + '

Foo

', + '

Foo

', + ]; + $exs[] = [ + // Messy looking URL + '

Foo

', + '

Foo

', + ]; + $exs[] = [ + // Messy looking URL, designed to trip-up quote handling + '

Foo

', + '

Foo

', + ]; + $exs[] = [ + // Messy looking URL, designed to trip-up quote handling + '

Foo

', + '

Foo

', + ]; + $exs[] = [ + // Messy looking URL, funny whitespace + '

Foo

', + '

Foo

', + ]; + $exs[] = [ + // Messy looking URL, funny whitespace + '

Foo

', + '

Foo

', + ]; + $exs[] = [ + // Many different URLs + '

FirstSecondThirdFourth

', + '

FirstSecondThirdFourth

', + ]; + + return $exs; + } + + /** + * @param $inputHtml + * @param $expectHtml + * @dataProvider getHrefExamples + */ + public function testReplaceTextUrls($inputHtml, $expectHtml) { + $inputText = \CRM_Utils_String::htmlToText($inputHtml); + $expectText = \CRM_Utils_String::htmlToText($expectHtml); + $expectText = str_replace('/tracking', 'tracking', $expectText); + $actual = TextClickTracker::replaceTextUrls($inputText, function($url) { + return "tracking($url)"; + }); + + $this->assertEquals($expectText, $actual, "Check substitutions on text ($inputText)"); + } + +} diff --git a/ext/flexmailer/tests/phpunit/Civi/FlexMailer/ConcurrentDeliveryTest.php b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/ConcurrentDeliveryTest.php new file mode 100644 index 0000000000..1daeab0640 --- /dev/null +++ b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/ConcurrentDeliveryTest.php @@ -0,0 +1,76 @@ +getManager(); + if ($manager->getStatus('org.civicrm.flexmailer') !== \CRM_Extension_Manager::STATUS_INSTALLED) { + $manager->install(array('org.civicrm.flexmailer')); + } + + parent::setUp(); + + \Civi::settings()->set('flexmailer_traditional', 'flexmailer'); + } + + public function tearDown() { + // We're building on someone else's test and don't fully trust them to + // protect our settings. Make sure they did. + $ok = ('flexmailer' == \Civi::settings()->get('flexmailer_traditional')) + && ('s:10:"flexmailer";' === \CRM_Core_DAO::singleValueQuery('SELECT value FROM civicrm_setting WHERE name ="flexmailer_traditional"')); + + parent::tearDown(); + + $this->assertTrue($ok, 'FlexMailer remained active during testing'); + } + + // ---- Boilerplate ---- + + // The remainder of this class contains dummy stubs which make it easier to + // work with the tests in an IDE. + + /** + * @dataProvider concurrencyExamples + * @see _testConcurrencyCommon + */ + public function testConcurrency($settings, $expectedTallies, $expectedTotal) { + parent::testConcurrency($settings, $expectedTallies, $expectedTotal); + } + + public function testBasic() { + parent::testBasic(); + } + +} diff --git a/ext/flexmailer/tests/phpunit/Civi/FlexMailer/FlexMailerSystemTest.php b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/FlexMailerSystemTest.php new file mode 100644 index 0000000000..876d0637e8 --- /dev/null +++ b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/FlexMailerSystemTest.php @@ -0,0 +1,129 @@ +getManager(); + if ($manager->getStatus('org.civicrm.flexmailer') !== \CRM_Extension_Manager::STATUS_INSTALLED) { + $manager->install(array('org.civicrm.flexmailer')); + } + + parent::setUp(); + \Civi::settings()->set('flexmailer_traditional', 'flexmailer'); + + $dispatcher = \Civi::service('dispatcher'); + foreach (FlexMailer::getEventTypes() as $event => $class) { + $dispatcher->addListener($event, array($this, 'handleEvent')); + } + + $hooks = \CRM_Utils_Hook::singleton(); + $hooks->setHook('civicrm_alterMailParams', + array($this, 'hook_alterMailParams')); + $this->counts = array(); + } + + public function handleEvent(Event $e) { + // We keep track of the events that fire during mail delivery. + // At the end, we'll ensure that the correct events fired. + $clazz = get_class($e); + if (!isset($this->counts[$clazz])) { + $this->counts[$clazz] = 1; + } + else { + $this->counts[$clazz]++; + } + } + + /** + * @see CRM_Utils_Hook::alterMailParams + */ + public function hook_alterMailParams(&$params, $context = NULL) { + $this->counts['hook_alterMailParams'] = 1; + $this->assertEquals('flexmailer', $context); + } + + public function tearDown() { + parent::tearDown(); + $this->assertNotEmpty($this->counts['hook_alterMailParams']); + foreach (FlexMailer::getEventTypes() as $event => $class) { + $this->assertTrue( + $this->counts[$class] > 0, + "If FlexMailer is active, $event should fire at least once." + ); + } + } + + // ---- Boilerplate ---- + + // The remainder of this class contains dummy stubs which make it easier to + // work with the tests in an IDE. + + /** + * Generate a fully-formatted mailing (with body_html content). + * + * @dataProvider urlTrackingExamples + */ + public function testUrlTracking( + $inputHtml, + $htmlUrlRegex, + $textUrlRegex, + $params + ) { + parent::testUrlTracking($inputHtml, $htmlUrlRegex, $textUrlRegex, $params); + } + + public function testBasicHeaders() { + parent::testBasicHeaders(); + } + + public function testText() { + parent::testText(); + } + + public function testHtmlWithOpenTracking() { + parent::testHtmlWithOpenTracking(); + } + + public function testHtmlWithOpenAndUrlTracking() { + parent::testHtmlWithOpenAndUrlTracking(); + } + +} diff --git a/ext/flexmailer/tests/phpunit/Civi/FlexMailer/Listener/SimpleFilterTest.php b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/Listener/SimpleFilterTest.php new file mode 100644 index 0000000000..4df278002a --- /dev/null +++ b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/Listener/SimpleFilterTest.php @@ -0,0 +1,97 @@ +getManager(); + if ($manager->getStatus('org.civicrm.flexmailer') !== \CRM_Extension_Manager::STATUS_INSTALLED) { + $manager->install(array('org.civicrm.flexmailer')); + } + + parent::setUp(); + } + + /** + * Ensure that the utility `SimpleFilter::byValue()` correctly filters. + */ + public function testByValue() { + $test = $this; + list($tasks, $e) = $this->createExampleBatch(); + + SimpleFilter::byValue($e, 'text', function ($value, $t, $e) use ($test) { + $test->assertInstanceOf('Civi\FlexMailer\FlexMailerTask', $t); + $test->assertInstanceOf('Civi\FlexMailer\Event\ComposeBatchEvent', $e); + $test->assertTrue(in_array($value, array( + 'eat more cheese', + 'eat more ice cream', + ))); + return preg_replace('/more/', 'thoughtfully considered quantities of', $value); + }); + + $this->assertEquals('eat thoughtfully considered quantities of cheese', $tasks[0]->getMailParam('text')); + $this->assertEquals('eat thoughtfully considered quantities of ice cream', $tasks[1]->getMailParam('text')); + } + + /** + * Ensure that the utility `SimpleFilter::byColumn()` correctly filters. + */ + public function testByColumn() { + $test = $this; + list($tasks, $e) = $this->createExampleBatch(); + + SimpleFilter::byColumn($e, 'text', function ($values, $e) use ($test) { + $test->assertInstanceOf('Civi\FlexMailer\Event\ComposeBatchEvent', $e); + $test->assertEquals('eat more cheese', $values[0]); + $test->assertEquals('eat more ice cream', $values[1]); + $test->assertEquals(2, count($values)); + return preg_replace('/more/', 'thoughtfully considered quantities of', $values); + }); + + $this->assertEquals('eat thoughtfully considered quantities of cheese', $tasks[0]->getMailParam('text')); + $this->assertEquals('eat thoughtfully considered quantities of ice cream', $tasks[1]->getMailParam('text')); + } + + /** + * @return array + */ + protected function createExampleBatch() { + $tasks = array(); + $tasks[0] = new FlexMailerTask(1000, 2000, 'asdf', 'foo@example.org'); + $tasks[1] = new FlexMailerTask(1001, 2001, 'fdsa', 'bar@example.org'); + + $e = new ComposeBatchEvent(array(), $tasks); + + $tasks[0]->setMailParam('text', 'eat more cheese'); + $tasks[1]->setMailParam('text', 'eat more ice cream'); + return array($tasks, $e); + } + +} diff --git a/ext/flexmailer/tests/phpunit/Civi/FlexMailer/MailingPreviewTest.php b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/MailingPreviewTest.php new file mode 100644 index 0000000000..32cdd17597 --- /dev/null +++ b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/MailingPreviewTest.php @@ -0,0 +1,156 @@ +getManager(); + if ($manager->getStatus('org.civicrm.flexmailer') !== \CRM_Extension_Manager::STATUS_INSTALLED) { + $manager->install(array('org.civicrm.flexmailer')); + } + + parent::setUp(); + + \Civi::settings()->set('flexmailer_traditional', 'flexmailer'); + + $this->useTransaction(); + // DGW + \CRM_Mailing_BAO_MailingJob::$mailsProcessed = 0; + $this->_contactID = $this->individualCreate(); + $this->_groupID = $this->groupCreate(); + $this->_email = 'test@test.test'; + $this->_params = array( + 'subject' => 'Hello {contact.display_name}', + 'body_text' => "This is {contact.display_name}.\nhttps://civicrm.org\nda=({domain.address}) optout=({action.optOutUrl}) subj=({mailing.subject})", + 'body_html' => "

This is {contact.display_name}.

CiviCRM.org

da=({domain.address}) optout=({action.optOutUrl}) subj=({mailing.subject})

", + 'name' => 'mailing name', + 'created_id' => $this->_contactID, + 'header_id' => '', + 'footer_id' => '', + ); + + $this->footer = civicrm_api3('MailingComponent', 'create', array( + 'name' => 'test domain footer', + 'component_type' => 'footer', + 'body_html' => '

From {domain.address}. To opt out, go to {action.optOutUrl}.

', + 'body_text' => 'From {domain.address}. To opt out, go to {action.optOutUrl}.', + )); + } + + public function tearDown() { + // DGW + \CRM_Mailing_BAO_MailingJob::$mailsProcessed = 0; + parent::tearDown(); + } + + public function testMailerPreview() { + // BEGIN SAMPLE DATA + $contactID = $this->individualCreate(); + $displayName = $this->callAPISuccess('contact', 'get', + array('id' => $contactID)); + $displayName = $displayName['values'][$contactID]['display_name']; + $this->assertTrue(!empty($displayName)); + + $params = $this->_params; + $params['api.Mailing.preview'] = array( + 'id' => '$value.id', + 'contact_id' => $contactID, + ); + $params['options']['force_rollback'] = 1; + // END SAMPLE DATA + + $maxIDs = $this->getMaxIds(); + $result = $this->callAPISuccess('mailing', 'create', $params); + $this->assertMaxIds($maxIDs); + + $previewResult = $result['values'][$result['id']]['api.Mailing.preview']; + $this->assertEquals("[CiviMail Draft] Hello $displayName", + $previewResult['values']['subject']); + + $this->assertContains("This is $displayName", $previewResult['values']['body_text']); + $this->assertContains("civicrm/mailing/optout", $previewResult['values']['body_text']); + $this->assertContains("&jid=&qid=&h=fakehash", $previewResult['values']['body_text']); + $this->assertContains("subj=(Hello ", $previewResult['values']['body_text']); + + $this->assertContains("

This is $displayName.

", $previewResult['values']['body_html']); + $this->assertContains("civicrm/mailing/optout", $previewResult['values']['body_html']); + $this->assertContains("&jid=&qid=&h=fakehash", $previewResult['values']['body_html']); + $this->assertContains("subj=(Hello ", $previewResult['values']['body_html']); + + $this->assertEquals('flexmailer', $previewResult['values']['_rendered_by_']); + } + + public function testMailerPreviewWithoutId() { + // BEGIN SAMPLE DATA + $contactID = $this->createLoggedInUser(); + $displayName = $this->callAPISuccess('contact', 'get', ['id' => $contactID]); + $displayName = $displayName['values'][$contactID]['display_name']; + $this->assertTrue(!empty($displayName)); + $params = $this->_params; + // END SAMPLE DATA + + $maxIDs = $this->getMaxIds(); + $previewResult = $this->callAPISuccess('mailing', 'preview', $params); + $this->assertMaxIds($maxIDs); + + $this->assertEquals("[CiviMail Draft] Hello $displayName", + $previewResult['values']['subject']); + + $this->assertContains("This is $displayName", $previewResult['values']['body_text']); + $this->assertContains("civicrm/mailing/optout", $previewResult['values']['body_text']); + $this->assertContains("&jid=&qid=&h=fakehash", $previewResult['values']['body_text']); + $this->assertContains("subj=(Hello ", $previewResult['values']['body_text']); + + $this->assertContains("

This is $displayName.

", $previewResult['values']['body_html']); + $this->assertContains("civicrm/mailing/optout", $previewResult['values']['body_html']); + $this->assertContains("&jid=&qid=&h=fakehash", $previewResult['values']['body_html']); + $this->assertContains("subj=(Hello ", $previewResult['values']['body_html']); + + $this->assertEquals('flexmailer', $previewResult['values']['_rendered_by_']); + } + + /** + * @return array + * Array(string $table => int $maxID). + */ + protected function getMaxIds() { + return array( + 'civicrm_mailing' => \CRM_Core_DAO::singleValueQuery('SELECT MAX(id) FROM civicrm_mailing'), + 'civicrm_mailing_job' => \CRM_Core_DAO::singleValueQuery('SELECT MAX(id) FROM civicrm_mailing_job'), + 'civicrm_mailing_group' => \CRM_Core_DAO::singleValueQuery('SELECT MAX(id) FROM civicrm_mailing_group'), + 'civicrm_mailing_recipients' => \CRM_Core_DAO::singleValueQuery('SELECT MAX(id) FROM civicrm_mailing_recipients'), + ); + } + + /** + * Assert that the given tables have the given extant IDs. + * + * @param array $expectMaxIds + * Array(string $table => int $maxId). + */ + protected function assertMaxIds($expectMaxIds) { + foreach ($expectMaxIds as $table => $maxId) { + $this->assertDBQuery($expectMaxIds[$table], 'SELECT MAX(id) FROM ' . $table, [], "Table $table should have a maximum ID of $maxId"); + } + } + +} diff --git a/ext/flexmailer/tests/phpunit/Civi/FlexMailer/ValidatorTest.php b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/ValidatorTest.php new file mode 100644 index 0000000000..3b5d999348 --- /dev/null +++ b/ext/flexmailer/tests/phpunit/Civi/FlexMailer/ValidatorTest.php @@ -0,0 +1,101 @@ +getManager(); + if ($manager->getStatus('org.civicrm.flexmailer') !== \CRM_Extension_Manager::STATUS_INSTALLED) { + $manager->install(array('org.civicrm.flexmailer')); + } + + parent::setUp(); + \Civi::settings()->set('flexmailer_traditional', 'flexmailer'); + } + + public function getExamples() { + $defaults = array( + 'id' => 123, + 'subject' => 'Default subject', + 'name' => 'Default name', + 'from_name' => 'Default sender', + 'from_email' => 'default@example.org', + 'body_html' => 'Default HTML body {action.unsubscribeUrl} {domain.address}', + 'body_text' => 'Default text body {action.unsubscribeUrl} {domain.address}', + 'template_type' => 'traditional', + 'template_options' => array(), + ); + + $es = array(); + $es[] = array( + array_merge($defaults, array('subject' => NULL)), + array('subject' => '/Field "subject" is required./'), + ); + $es[] = array( + array_merge($defaults, array('subject' => NULL, 'from_name' => NULL)), + array( + 'subject' => '/Field "subject" is required./', + 'from_name' => '/Field "from_name" is required./', + ), + ); + $es[] = array( + array_merge($defaults, array('body_text' => NULL)), + array(), + ); + $es[] = array( + array_merge($defaults, array('body_html' => NULL)), + array(), + ); + $es[] = array( + array_merge($defaults, array('body_html' => NULL, 'body_text' => NULL)), + array('(body_html|body_text)' => '/Field "body_html" or "body_text" is required./'), + ); + $es[] = array( + array_merge($defaults, array('body_html' => 'Muahaha. I omit the mandatory tokens!')), + array( + 'body_html:domain.address' => '/This message is missing.*postal address/', + 'body_html:action.optOutUrl or action.unsubscribeUrl' => '/This message is missing.*Unsubscribe via web page/', + ), + ); + $es[] = array( + array_merge($defaults, array('body_html' => 'I omit the mandatory tokens, but checking them is someone else\'s job!', 'template_type' => 'esperanto')), + array(), + ); + return $es; + } + + /** + * @param array $mailingData + * Mailing content (per CRM_Mailing_DAO_Mailing) as an array. + * @param array $expectedErrors + * @dataProvider getExamples + */ + public function testExamples($mailingData, $expectedErrors) { + $mailing = new \CRM_Mailing_DAO_Mailing(); + $mailing->copyValues($mailingData); + $actualErrors = Validator::createAndRun($mailing); + $this->assertEquals( + array_keys($actualErrors), + array_keys($expectedErrors) + ); + foreach ($expectedErrors as $key => $pat) { + $this->assertRegExp($pat, $actualErrors[$key], "Error for \"$key\" should match pattern"); + } + } + +} diff --git a/ext/flexmailer/tests/phpunit/bootstrap.php b/ext/flexmailer/tests/phpunit/bootstrap.php new file mode 100644 index 0000000000..048cc4b260 --- /dev/null +++ b/ext/flexmailer/tests/phpunit/bootstrap.php @@ -0,0 +1,64 @@ +apply(); +} + +/** + * Call the "cv" command. + * + * @param string $cmd + * The rest of the command to send. + * @param string $decode + * Ex: 'json' or 'phpcode'. + * @return string + * Response output (if the command executed normally). + * @throws \RuntimeException + * If the command terminates abnormally. + */ +function cv($cmd, $decode = 'json') { + $cmd = 'cv ' . $cmd; + $descriptorSpec = array(0 => array("pipe", "r"), 1 => array("pipe", "w"), 2 => STDERR); + $oldOutput = getenv('CV_OUTPUT'); + putenv("CV_OUTPUT=json"); + $process = proc_open($cmd, $descriptorSpec, $pipes, __DIR__); + putenv("CV_OUTPUT=$oldOutput"); + fclose($pipes[0]); + $result = stream_get_contents($pipes[1]); + fclose($pipes[1]); + if (proc_close($process) !== 0) { + throw new RuntimeException("Command failed ($cmd):\n$result"); + } + switch ($decode) { + case 'raw': + return $result; + + case 'phpcode': + // If the last output is /*PHPCODE*/, then we managed to complete execution. + if (substr(trim($result), 0, 12) !== "/*BEGINPHP*/" || substr(trim($result), -10) !== "/*ENDPHP*/") { + throw new \RuntimeException("Command failed ($cmd):\n$result"); + } + return $result; + + case 'json': + return json_decode($result, 1); + + default: + throw new RuntimeException("Bad decoder format ($decode)"); + } +} diff --git a/ext/flexmailer/xml/Menu/flexmailer.xml b/ext/flexmailer/xml/Menu/flexmailer.xml new file mode 100644 index 0000000000..605cfa1228 --- /dev/null +++ b/ext/flexmailer/xml/Menu/flexmailer.xml @@ -0,0 +1,11 @@ + + + + civicrm/admin/setting/flexmailer + CRM_Admin_Form_Generic + Flexmailer Settings + CiviMail + admin/small/Profile.png + administer CiviCRM + + -- 2.25.1