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;