diff --git a/app/DataMapper/EmailTemplateDefaults.php b/app/DataMapper/EmailTemplateDefaults.php index 1a7fb19ac75a..60c40c819fb3 100644 --- a/app/DataMapper/EmailTemplateDefaults.php +++ b/app/DataMapper/EmailTemplateDefaults.php @@ -241,7 +241,7 @@ class EmailTemplateDefaults public static function emailStatementTemplate() { - $statement_message = '

$client

'.self::transformText('client_statement_body').'

$invoices

'; + $statement_message = '

$client

'.self::transformText('client_statement_body').'

'; return $statement_message; diff --git a/app/Services/Email/EmailDefaults.php b/app/Services/Email/EmailDefaults.php new file mode 100644 index 000000000000..6faeadccfc31 --- /dev/null +++ b/app/Services/Email/EmailDefaults.php @@ -0,0 +1,244 @@ +settings = $this->email_object->settings; + + $this->setLocale() + ->setFrom() + ->setTemplate() + ->setBody() + ->setSubject() + ->setReplyTo() + ->setBcc() + ->setAttachments() + ->setMetaData() + ->setVariables(); + + return $this->email_object; + + } + + private function setMetaData(): self + { + + $this->email_object->company_key = $this->email_service->company->company_key; + + $this->email_object->logo = $this->email_service->company->present()->logo(); + + $this->email_object->signature = $this->email_object->signature ?: $this->settings->email_signature; + + $this->email_object->whitelabel = $this->email_object->company->account->isPaid() ? true : false; + + return $this; + + } + + private function setLocale(): self + { + + if($this->email_object->client) + $this->locale = $this->email_object->client->locale(); + elseif($this->email_object->vendor) + $this->locale = $this->email_object->vendor->locale(); + else + $this->locale = $this->email_service->company->locale(); + + App::setLocale($this->locale); + App::forgetInstance('translator'); + $t = app('translator'); + $t->replace(Ninja::transformTranslations($this->settings)); + + return $this; + } + + private function setTemplate(): self + { + $this->template = $this->email_object->settings->email_style; + + match($this->email_object->settings->email_style){ + 'light' => $this->template = 'email.template.client', + 'dark' => $this->template = 'email.template.client', + 'custom' => $this->template = 'email.template.custom', + default => $this->template = 'email.template.client', + }; + + $this->email_object->html_template = $this->template; + + return $this; + } + + private function setFrom(): self + { + if($this->email_object->from) + return $this; + + $this->email_object->from = new Address($this->email_service->company->owner()->email, $this->email_service->company->owner()->name()); + + return $this; + + } + + //think about where we do the string replace for variables.... + private function setBody(): self + { + + if($this->email_object->body){ + $this->email_object->body = $this->email_object->body; + } + elseif(strlen($this->email_object->settings->{$this->email_object->email_template_body}) > 3){ + $this->email_object->body = $this->email_object->settings->{$this->email_object->email_template_body}; + } + else{ + $this->email_object->body = EmailTemplateDefaults::getDefaultTemplate($this->email_object->email_template_body, $this->locale); + } + + if($this->template == 'email.template.custom'){ + $this->email_object->body = (str_replace('$body', $this->email_object->body, $this->email_object->settings->email_style_custom)); + } + + return $this; + + } + + //think about where we do the string replace for variables.... + private function setSubject(): self + { + + if ($this->email_object->subject) //where the user updates the subject from the UI + return $this; + elseif(strlen($this->email_object->settings->{$this->email_object->email_template_subject}) > 3) + $this->email_object->subject = $this->email_object->settings->{$this->email_object->email_template_subject}; + else + $this->email_object->subject = EmailTemplateDefaults::getDefaultTemplate($this->email_object->email_template_subject, $this->locale); + + return $this; + + } + + public function setVariables(): self + { + + $this->email_object->body = strtr($this->email_object->body, $this->email_object->variables); + + $this->email_object->subject = strtr($this->email_object->subject, $this->email_object->variables); + + if($this->template != 'custom') + $this->email_object->body = $this->parseMarkdownToHtml($this->email_object->body); + + return $this; + } + + private function setReplyTo(): self + { + + $reply_to_email = str_contains($this->email_object->settings->reply_to_email, "@") ? $this->email_object->settings->reply_to_email : $this->email_service->company->owner()->email; + + $reply_to_name = strlen($this->email_object->settings->reply_to_name) > 3 ? $this->email_object->settings->reply_to_name : $this->email_service->company->owner()->present()->name(); + + $this->email_object->reply_to = array_merge($this->email_object->reply_to, [new Address($reply_to_email, $reply_to_name)]); + + return $this; + } + + private function setBcc(): self + { + $bccs = []; + $bcc_array = []; + + if (strlen($this->email_object->settings->bcc_email) > 1) { + + if (Ninja::isHosted() && $this->email_service->company->account->isPaid()) { + $bccs = array_slice(explode(',', str_replace(' ', '', $this->email_object->settings->bcc_email)), 0, 2); + } else { + $bccs(explode(',', str_replace(' ', '', $this->email_object->settings->bcc_email))); + } + } + + foreach($bccs as $bcc) + { + $bcc_array[] = new Address($bcc); + } + + $this->email_object->bcc = array_merge($this->email_object->bcc, $bcc_array); + + return $this; + } + + private function buildCc() + { + return [ + + ]; + } + + private function setAttachments(): self + { + $attachments = []; + + if ($this->email_object->settings->document_email_attachment && $this->email_service->company->account->hasFeature(Account::FEATURE_DOCUMENTS)) { + + foreach ($this->email_service->company->documents as $document) { + + $attachments[] = ['file' => base64_encode($document->getFile()), 'name' => $document->name]; + + } + + } + + $this->email_object->attachments = array_merge($this->email_object->attachments, $attachments); + + return $this; + + } + + private function setHeaders(): self + { + if($this->email_object->invitation_key) + $this->email_object->headers = array_merge($this->email_object->headers, ['x-invitation-key' => $this->email_object->invitation_key]); + + return $this; + } + + public function parseMarkdownToHtml(string $markdown): ?string + { + $converter = new CommonMarkConverter([ + 'allow_unsafe_links' => false, + ]); + + return $converter->convert($markdown); + } + +} \ No newline at end of file diff --git a/app/Services/Email/EmailMailable.php b/app/Services/Email/EmailMailable.php new file mode 100644 index 000000000000..9dc0e38d5295 --- /dev/null +++ b/app/Services/Email/EmailMailable.php @@ -0,0 +1,109 @@ +email_object->subject, + tags: [$this->email_object->company_key], + replyTo: $this->email_object->reply_to, + from: $this->email_object->from, + to: $this->email_object->to, + bcc: $this->email_object->bcc + ); + } + + /** + * Get the message content definition. + * + * @return \Illuminate\Mail\Mailables\Content + */ + public function content() + { + return new Content( + view: $this->email_object->template, + text: $this->email_object->text_template, + with: [ + 'text_body' => strip_tags($this->email_object->body), + 'body' => $this->email_object->body, + 'settings' => $this->email_object->settings, + 'whitelabel' => $this->email_object->whitelabel, + 'logo' => $this->email_object->logo, + 'signature' => $this->email_object->signature, + 'company' => $this->email_object->company, + 'greeting' => '' + ] + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments() + { + + $attachments = []; + + foreach($this->email_object->attachments as $file) + { + $attachments[] = Attachment::fromData(fn () => base64_decode($file['file']), $file['name']); + } + + return $attachments; + + } + + /** + * Get the message headers. + * + * @return \Illuminate\Mail\Mailables\Headers + */ + public function headers() + { + + return new Headers( + messageId: null, + references: [], + text: $this->email_object->headers, + ); + + } + +} diff --git a/app/Services/Email/EmailMailer.php b/app/Services/Email/EmailMailer.php new file mode 100644 index 000000000000..f6e4653bfcd8 --- /dev/null +++ b/app/Services/Email/EmailMailer.php @@ -0,0 +1,508 @@ +email_service->company->db); + + //decode all attachments + $this->setMailDriver(); + + $mailer = Mail::mailer($this->mailer); + + if($this->client_postmark_secret){ + nlog("inside postmark config"); + nlog($this->client_postmark_secret); + $mailer->postmark_config($this->client_postmark_secret); + } + + if($this->client_mailgun_secret){ + $mailer->mailgun_config($this->client_mailgun_secret, $this->client_mailgun_domain); + } + + + //send email + try { + + nlog("Using mailer => ". $this->mailer. " ". now()->toDateTimeString()); + $mailer->send($this->email_mailable); + + Cache::increment($this->email_service->company->account->key); + + LightLogs::create(new EmailSuccess($this->email_service->company->company_key)) + ->send(); + + } catch (\Exception | \RuntimeException | \Google\Service\Exception $e) { + + nlog("error failed with {$e->getMessage()}"); + + $this->cleanUpMailers(); + + $message = $e->getMessage(); + + /** + * Post mark buries the proper message in a a guzzle response + * this merges a text string with a json object + * need to harvest the ->Message property using the following + */ + if($e instanceof ClientException) { //postmark specific failure + + $response = $e->getResponse(); + $message_body = json_decode($response->getBody()->getContents()); + + if($message_body && property_exists($message_body, 'Message')){ + $message = $message_body->Message; + nlog($message); + } + + } + + /* If the is an entity attached to the message send a failure mailer */ + $this->entityEmailFailed($message); + + /* Don't send postmark failures to Sentry */ + if(Ninja::isHosted() && (!$e instanceof ClientException)) + app('sentry')->captureException($e); + + $message = null; + // $this->email_service = null; + // $this->email_mailable = null; + + } + + } + + /** + * Entity notification when an email fails to send + * + * @param string $message + * @return void + */ + private function entityEmailFailed($message) + { + + if(!$this->email_service->email_object->entity_id) + return; + + switch ($this->email_service->email_object->entity_class) { + case Invoice::class: + $invitation = InvoiceInvitation::withTrashed()->find($this->email_service->email_object->entity_id); + if($invitation) + event(new InvoiceWasEmailedAndFailed($invitation, $this->email_service->company, $message, $this->email_service->email_object->reminder_template, Ninja::eventVars(auth()->user() ? auth()->user()->id : null))); + break; + case Payment::class: + $payment = Payment::withTrashed()->find($this->email_service->email_object->entity_id); + if($payment) + event(new PaymentWasEmailedAndFailed($payment, $this->email_service->company, $message, Ninja::eventVars(auth()->user() ? auth()->user()->id : null))); + break; + default: + # code... + break; + } + + if ($this->email_service->email_object->client_contact instanceof ClientContact) + $this->logMailError($message, $this->email_service->email_object->client_contact); + + } + + private function setMailDriver(): self + { + + switch ($this->email_service->email_object->settings->email_sending_method) { + case 'default': + $this->mailer = config('mail.default'); + break; + case 'gmail': + $this->mailer = 'gmail'; + $this->setGmailMailer(); + return $this; + case 'office365': + $this->mailer = 'office365'; + $this->setOfficeMailer(); + return $this; + case 'client_postmark': + $this->mailer = 'postmark'; + $this->setPostmarkMailer(); + return $this; + case 'client_mailgun': + $this->mailer = 'mailgun'; + $this->setMailgunMailer(); + return $this; + + default: + break; + } + + if(Ninja::isSelfHost()) + $this->setSelfHostMultiMailer(); + + return $this; + + } + + /** + * Allows configuration of multiple mailers + * per company for use by self hosted users + */ + private function setSelfHostMultiMailer(): void + { + + if (env($this->email_service->company->id . '_MAIL_HOST')) + { + + config([ + 'mail.mailers.smtp' => [ + 'transport' => 'smtp', + 'host' => env($this->email_service->company->id . '_MAIL_HOST'), + 'port' => env($this->email_service->company->id . '_MAIL_PORT'), + 'username' => env($this->email_service->company->id . '_MAIL_USERNAME'), + 'password' => env($this->email_service->company->id . '_MAIL_PASSWORD'), + ], + ]); + + if(env($this->email_service->company->id . '_MAIL_FROM_ADDRESS')) + { + $this->email_mailable + ->from(env($this->email_service->company->id . '_MAIL_FROM_ADDRESS', env('MAIL_FROM_ADDRESS')), env($this->email_service->company->id . '_MAIL_FROM_NAME', env('MAIL_FROM_NAME'))); + } + + } + + } + + + /** + * Ensure we discard any data that is not required + * + * @return void + */ + private function cleanUpMailers(): void + { + $this->client_postmark_secret = false; + + $this->client_mailgun_secret = false; + + $this->client_mailgun_domain = false; + + //always dump the drivers to prevent reuse + app('mail.manager')->forgetMailers(); + } + + /** + * Check to ensure no cross account + * emails can be sent. + * + * @param User $user + */ + private function checkValidSendingUser($user) + { + /* Always ensure the user is set on the correct account */ + if($user->account_id != $this->email_service->company->account_id){ + + $this->email_service->email_object->settings->email_sending_method = 'default'; + return $this->setMailDriver(); + } + } + + /** + * Resolves the sending user + * when configuring the Mailer + * on behalf of the client + * + * @return User $user + */ + private function resolveSendingUser(): ?User + { + $sending_user = $this->email_service->email_object->settings->gmail_sending_user_id; + + $user = User::find($this->decodePrimaryKey($sending_user)); + + return $user; + } + + /** + * Configures Mailgun using client supplied secret + * as the Mailer + */ + private function setMailgunMailer() + { + if(strlen($this->email_service->email_object->settings->mailgun_secret) > 2 && strlen($this->email_service->email_object->settings->mailgun_domain) > 2){ + $this->client_mailgun_secret = $this->email_service->email_object->settings->mailgun_secret; + $this->client_mailgun_domain = $this->email_service->email_object->settings->mailgun_domain; + } + else{ + $this->email_service->email_object->settings->email_sending_method = 'default'; + return $this->setMailDriver(); + } + + + $user = $this->resolveSendingUser(); + + $this->mailable + ->from($user->email, $user->name()); + } + + /** + * Configures Postmark using client supplied secret + * as the Mailer + */ + private function setPostmarkMailer() + { + if(strlen($this->email_service->email_object->settings->postmark_secret) > 2){ + $this->client_postmark_secret = $this->email_service->email_object->settings->postmark_secret; + } + else{ + $this->email_service->email_object->settings->email_sending_method = 'default'; + return $this->setMailDriver(); + } + + $user = $this->resolveSendingUser(); + + $this->mailable + ->from($user->email, $user->name()); + } + + /** + * Configures Microsoft via Oauth + * as the Mailer + */ + private function setOfficeMailer() + { + $user = $this->resolveSendingUser(); + + $this->checkValidSendingUser($user); + + nlog("Sending via {$user->name()}"); + + $token = $this->refreshOfficeToken($user); + + if($token) + { + $user->oauth_user_token = $token; + $user->save(); + + } + else { + + $this->email_service->email_object->settings->email_sending_method = 'default'; + return $this->setMailDriver(); + + } + + $this->mailable + ->from($user->email, $user->name()) + ->withSymfonyMessage(function ($message) use($token) { + $message->getHeaders()->addTextHeader('gmailtoken', $token); + }); + + sleep(rand(1,3)); + } + + /** + * Configures GMail via Oauth + * as the Mailer + */ + private function setGmailMailer() + { + + $user = $this->resolveSendingUser(); + + $this->checkValidSendingUser($user); + + nlog("Sending via {$user->name()}"); + + $google = (new Google())->init(); + + try{ + + if ($google->getClient()->isAccessTokenExpired()) { + $google->refreshToken($user); + $user = $user->fresh(); + } + + $google->getClient()->setAccessToken(json_encode($user->oauth_user_token)); + + sleep(rand(2,4)); + } + catch(\Exception $e) { + $this->logMailError('Gmail Token Invalid', $this->email_service->company->clients()->first()); + $this->email_service->email_object->settings->email_sending_method = 'default'; + return $this->setMailDriver(); + } + + /** + * If the user doesn't have a valid token, notify them + */ + + if(!$user->oauth_user_token) { + $this->email_service->company->account->gmailCredentialNotification(); + $this->email_service->email_object->settings->email_sending_method = 'default'; + return $this->setMailDriver(); + } + + /* + * Now that our token is refreshed and valid we can boot the + * mail driver at runtime and also set the token which will persist + * just for this request. + */ + + $token = $user->oauth_user_token->access_token; + + if(!$token) { + $this->email_service->company->account->gmailCredentialNotification(); + $this->email_service->email_object->settings->email_sending_method = 'default'; + return $this->setMailDriver(); + } + + $this->mailable + ->from($user->email, $user->name()) + ->withSymfonyMessage(function ($message) use($token) { + $message->getHeaders()->addTextHeader('gmailtoken', $token); + }); + + } + + /** + * Logs any errors to the SystemLog + * + * @param string $errors + * @param App\Models\User | App\Models\Client $recipient_object + * @return void + */ + private function logMailError($errors, $recipient_object) :void + { + + (new SystemLogger( + $errors, + SystemLog::CATEGORY_MAIL, + SystemLog::EVENT_MAIL_SEND, + SystemLog::TYPE_FAILURE, + $recipient_object, + $this->email_service->company + ))->handle(); + + $job_failure = new EmailFailure($this->email_service->company->company_key); + $job_failure->string_metric5 = 'failed_email'; + $job_failure->string_metric6 = substr($errors, 0, 150); + + LightLogs::create($job_failure) + ->send(); + + $job_failure = null; + + } + + /** + * Attempts to refresh the Microsoft refreshToken + * + * @param App\Models\User + * @return string | bool + */ + private function refreshOfficeToken($user) + { + $expiry = $user->oauth_user_token_expiry ?: now()->subDay(); + + if($expiry->lt(now())) + { + $guzzle = new \GuzzleHttp\Client(); + $url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'; + + $token = json_decode($guzzle->post($url, [ + 'form_params' => [ + 'client_id' => config('ninja.o365.client_id') , + 'client_secret' => config('ninja.o365.client_secret') , + 'scope' => 'email Mail.Send offline_access profile User.Read openid', + 'grant_type' => 'refresh_token', + 'refresh_token' => $user->oauth_user_refresh_token + ], + ])->getBody()->getContents()); + + if($token){ + + $user->oauth_user_refresh_token = property_exists($token, 'refresh_token') ? $token->refresh_token : $user->oauth_user_refresh_token; + $user->oauth_user_token = $token->access_token; + $user->oauth_user_token_expiry = now()->addSeconds($token->expires_in); + $user->save(); + + return $token->access_token; + } + + return false; + } + + return $user->oauth_user_token; + + } + + public function failed($exception = null) + { + + } + +} diff --git a/app/Services/Email/EmailObject.php b/app/Services/Email/EmailObject.php new file mode 100644 index 000000000000..27a1dd72bdfa --- /dev/null +++ b/app/Services/Email/EmailObject.php @@ -0,0 +1,84 @@ +override = $override; + + +nlog($this->email_object->subject); +nlog($this->email_object->body); + + $this->setDefaults() + ->updateMailable() + ->email(); + } + + public function sendNow($override = false) :void + { + $this->setDefaults() + ->updateMailable() + ->email(true); + } + + private function email($force = false): void + { + if($force) + (new EmailMailer($this, $this->mailable))->handle(); + else + EmailMailer::dispatch($this, $this->mailable)->delay(2); + + } + + private function setDefaults(): self + { + $defaults = new EmailDefaults($this, $this->email_object); + $defaults->run(); + + return $this; + } + + private function updateMailable() + { + $this->mailable = new EmailMailable($this->email_object); + + return $this; + } + +} \ No newline at end of file diff --git a/app/Services/Scheduler/SchedulerService.php b/app/Services/Scheduler/SchedulerService.php index f17dab7c9b61..d8b1612e5540 100644 --- a/app/Services/Scheduler/SchedulerService.php +++ b/app/Services/Scheduler/SchedulerService.php @@ -109,7 +109,17 @@ class SchedulerService $email_object = new EmailObject; $email_object->to = [new Address($this->client->present()->email(), $this->client->present()->name())]; - $email_object->attachments = ['name' => ctrans('texts.statement') . ".pdf", 'file' => base64_encode($pdf)]; + $email_object->attachments = [['file' => base64_encode($pdf), 'name' => ctrans('texts.statement') . ".pdf"]]; + $email_object->settings = $this->client->getMergedSettings(); + $email_object->company = $this->client->company; + $email_object->client = $this->client; + $email_object->email_template_subject = 'email_subject_statement'; + $email_object->email_template_body = 'email_template_statement'; + $email_object->variables = [ + '$client' => $this->client->present()->name(), + '$start_date' => $this->client_start_date, + '$end_date' => $this->client_end_date, + ]; return $email_object;