· 6 months ago · Mar 27, 2025, 08:15 PM
1<?php
2
3namespace App\Services;
4
5use Exception;
6use PHPMailer\PHPMailer\PHPMailer;
7use PHPMailer\PHPMailer\Exception as PHPMailerException;
8use PHPMailer\PHPMailer\SMTP;
9use App\Services\Email\EmailTemplateRenderer;
10use App\Services\Email\HtmlValidator;
11use App\Services\Email\EmailRateLimiter;
12use App\Services\Email\EmailSecurityManager;
13
14/**
15 * Email Service
16 *
17 * Core email service that handles basic email sending functionality.
18 *
19 * @package App\Services
20 */
21class EmailService
22{
23 /**
24 * @var PHPMailer PHPMailer instance
25 */
26 private PHPMailer $mailer;
27
28 /**
29 * @var Logger Logger service
30 */
31 private Logger $logger;
32
33 /**
34 * @var SecurityService Security service
35 */
36 private SecurityService $securityService;
37
38 /**
39 * @var array Default email configuration
40 */
41 private array $config;
42
43 /**
44 * @var EmailTemplateRenderer Template renderer
45 */
46 private EmailTemplateRenderer $templateRenderer;
47
48 /**
49 * @var HtmlValidator HTML validator
50 */
51 private HtmlValidator $htmlValidator;
52
53 /**
54 * @var EmailRateLimiter Rate limiter
55 */
56 private EmailRateLimiter $rateLimiter;
57
58 /**
59 * @var EmailSecurityManager Security manager
60 */
61 private EmailSecurityManager $securityManager;
62
63 /**
64 * EmailService constructor
65 *
66 * @param array $config Custom email configuration
67 * @param int $maxEmailsPerHour Maximum emails per hour for rate limiting
68 * @throws PHPMailerException
69 */
70 public function __construct(array $config = [], int $maxEmailsPerHour = 10)
71 {
72 $this->logger = new Logger();
73 $this->securityService = new SecurityService();
74 $this->templateRenderer = new EmailTemplateRenderer();
75 $this->htmlValidator = new HtmlValidator();
76 $this->rateLimiter = new EmailRateLimiter($maxEmailsPerHour);
77 $this->securityManager = new EmailSecurityManager();
78
79 // Default configuration from constants
80 $this->config = [
81 'host' => MAIL_HOST,
82 'port' => MAIL_PORT,
83 'username' => MAIL_USERNAME,
84 'password' => MAIL_PASSWORD,
85 'encryption' => MAIL_ENCRYPTION,
86 'from_address' => MAIL_FROM_ADDRESS,
87 'from_name' => MAIL_FROM_NAME,
88 'debug' => APP_DEBUG ? 1 : 0,
89 ];
90
91 // Override with custom config if provided
92 if (!empty($config)) {
93 $this->config = array_merge($this->config, $config);
94 }
95
96 $this->initializeMailer();
97 }
98
99 /**
100 * Initialize PHPMailer with config settings
101 *
102 * @return void
103 * @throws PHPMailerException
104 */
105 private function initializeMailer(): void
106 {
107 $this->mailer = new PHPMailer(true);
108
109 // Server settings
110 $this->mailer->isSMTP();
111 $this->mailer->Host = $this->config['host'];
112 $this->mailer->SMTPAuth = true;
113 $this->mailer->Username = $this->config['username'];
114 $this->mailer->Password = $this->config['password'];
115 $this->mailer->SMTPSecure = $this->config['encryption'];
116 $this->mailer->Port = $this->config['port'];
117 $this->mailer->CharSet = 'UTF-8';
118
119 // Debug settings
120 if (APP_ENV === 'development' || $this->config['debug'] > 0) {
121 // Ensure log directory exists
122 $logDir = ROOT_DIR . '/logs/track-mail';
123 if (!is_dir($logDir)) {
124 mkdir($logDir, 0755, true);
125 }
126
127 // Generate log file name with timestamp
128 $timestamp = date('Y-m-d_H-i-s');
129 $logFile = $logDir . '/mail_' . $timestamp . '.log';
130
131 // Set debug level
132 $this->mailer->SMTPDebug = SMTP::DEBUG_SERVER;
133
134 // Custom debug output handler that writes to log file
135 $this->mailer->Debugoutput = function ($str, $level) use ($logFile) {
136 // Append to log file
137 file_put_contents($logFile, $str . PHP_EOL, FILE_APPEND);
138 };
139 } else {
140 $this->mailer->SMTPDebug = 0; // Turn off debugging in production
141 }
142
143 // Default sender
144 $this->mailer->setFrom(
145 $this->config['from_address'],
146 $this->config['from_name']
147 );
148
149 // Set default reply-to
150 $this->mailer->addReplyTo(
151 $this->config['from_address'],
152 $this->config['from_name']
153 );
154 }
155
156 /**
157 * Send an email
158 *
159 * @param string|array $to Recipient(s)
160 * @param string $subject Email subject
161 * @param string $body Email body (HTML)
162 * @param string|null $plainText Plain text alternative
163 * @param array $attachments Attachments [path => name]
164 * @param array $cc CC recipients [email => name]
165 * @param array $bcc BCC recipients [email => name]
166 * @return bool True if email was sent successfully
167 * @throws Exception If email sending fails
168 */
169 public function send(
170 string|array $to,
171 string $subject,
172 string $body,
173 ?string $plainText = null,
174 array $attachments = [],
175 array $cc = [],
176 array $bcc = []
177 ): bool
178 {
179 try {
180 // Check if we're being rate limited
181 if (!$this->rateLimiter->checkRateLimit()) {
182 $this->logger->warning('Email rate limit exceeded', [
183 'to' => $to,
184 'subject' => $subject
185 ]);
186 throw new Exception('Email sending rate limit exceeded. Please try again later.');
187 }
188
189 // Log email attempt
190 $this->rateLimiter->logEmailAttempt($to, $subject);
191
192 // Reset recipients
193 $this->mailer->clearAllRecipients();
194 $this->mailer->clearAttachments();
195
196 // Add recipient(s)
197 if (is_array($to)) {
198 foreach ($to as $email => $name) {
199 if (is_numeric($email)) {
200 $this->mailer->addAddress($name); // If just email without name
201 } else {
202 $this->mailer->addAddress($email, $name);
203 }
204 }
205 } else {
206 $this->mailer->addAddress($to);
207 }
208
209 // Add CC recipients
210 foreach ($cc as $email => $name) {
211 if (is_numeric($email)) {
212 $this->mailer->addCC($name); // If just email without name
213 } else {
214 $this->mailer->addCC($email, $name);
215 }
216 }
217
218 // Add BCC recipients
219 foreach ($bcc as $email => $name) {
220 if (is_numeric($email)) {
221 $this->mailer->addBCC($name); // If just email without name
222 } else {
223 $this->mailer->addBCC($email, $name);
224 }
225 }
226
227 // Add attachments
228 foreach ($attachments as $path => $name) {
229 $this->mailer->addAttachment($path, $name);
230 }
231
232 // Set email content
233 $this->mailer->isHTML(true);
234 $this->mailer->Subject = $subject;
235
236 $body = $this->htmlValidator->validate($body);
237
238 $this->mailer->Body = $body;
239
240 // Set plain text alternative if provided
241 if ($plainText !== null) {
242 $this->mailer->AltBody = $plainText;
243 } else {
244 // Generate plain text from HTML
245 $this->mailer->AltBody = $this->templateRenderer->htmlToPlainText($body);
246 }
247
248 // Add security headers to prevent email spoofing
249 $this->securityManager->addSecurityHeaders($this->mailer);
250
251 // Send the email
252 $result = $this->mailer->send();
253
254 // Log successful sending
255 $this->logger->info('Email sent successfully', [
256 'to' => $to,
257 'subject' => $subject
258 ]);
259
260 return $result;
261 } catch (PHPMailerException $e) {
262 // Log the error
263 $this->logger->error('Email sending failed', [
264 'error' => $e->getMessage(),
265 'to' => $to,
266 'subject' => $subject
267 ]);
268
269 // Log security event
270 $this->securityService->logSecurityEvent(
271 'email_error',
272 [
273 'error' => $e->getMessage(),
274 'to' => is_array($to) ? implode(', ', array_keys($to)) : $to,
275 'subject' => $subject
276 ],
277 'warning'
278 );
279
280 throw new Exception('Failed to send email: ' . $e->getMessage());
281 }
282 }
283
284 /**
285 * Reset mailer instance with fresh configuration
286 *
287 * @return void
288 * @throws PHPMailerException
289 */
290 public function resetMailer(): void
291 {
292 $this->initializeMailer();
293 }
294
295 /**
296 * Get the PHPMailer instance for advanced configuration
297 *
298 * @return PHPMailer PHPMailer instance
299 */
300 public function getMailer(): PHPMailer
301 {
302 return $this->mailer;
303 }
304
305 /**
306 * Set a custom reply-to address
307 *
308 * @param string $email Reply-to email
309 * @param string $name Reply-to name
310 * @return void
311 * @throws PHPMailerException
312 */
313 public function setReplyTo(string $email, string $name = ''): void
314 {
315 $this->mailer->clearReplyTos();
316 $this->mailer->addReplyTo($email, $name);
317 }
318
319 /**
320 * Get the template renderer
321 *
322 * @return EmailTemplateRenderer
323 */
324 public function getTemplateRenderer(): EmailTemplateRenderer
325 {
326 return $this->templateRenderer;
327 }
328
329 /**
330 * Get the HTML validator
331 *
332 * @return HtmlValidator
333 */
334 public function getHtmlValidator(): HtmlValidator
335 {
336 return $this->htmlValidator;
337 }
338}