├── .gitignore ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src ├── Mail.php ├── config.php ├── facade │ └── Mail.php └── mail │ ├── Mailable.php │ ├── Mailer.php │ ├── Markdown.php │ ├── Message.php │ ├── SendQueuedMailable.php │ ├── resource │ ├── css │ │ └── default.css │ └── view │ │ ├── button.twig │ │ ├── code.twig │ │ ├── footer.twig │ │ ├── header.twig │ │ ├── layout.twig │ │ ├── message.twig │ │ ├── panel.twig │ │ ├── promotion.twig │ │ ├── subcopy.twig │ │ └── table.twig │ └── twig │ ├── Node │ └── Component.php │ └── TokenParser │ └── Component.php └── tests ├── TwigComponentTest.php ├── bootstrap.php └── fixtures └── normal.twig /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor 3 | composer.lock 4 | /runtime 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ThinkPHP6 邮件发送扩展 2 | 3 | 支持 `smtp` `sendmail` `mail` `sendcloud` `log` 等驱动,其中`log`驱动会把邮件内容写入日志,供调试用 4 | 5 | ## 安装 6 | ~~~ 7 | composer require yunwuxin/think-mail 8 | ~~~ 9 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yunwuxin/think-mail", 3 | "description": "The Mailer Library For ThinkPHP6", 4 | "require": { 5 | "php": ">=8.0", 6 | "topthink/think-queue": "^3.0", 7 | "cebe/markdown": "^1.1", 8 | "yunwuxin/think-twig": "^3.0", 9 | "tijsverkoyen/css-to-inline-styles": "^2.2", 10 | "topthink/framework": "^6.0|^8.0", 11 | "nette/mail": "^4.0" 12 | }, 13 | "require-dev": { 14 | "mikey179/vfsstream": "^1.6", 15 | "mockery/mockery": "^1.2", 16 | "phpunit/phpunit": "^7.0|^8.0" 17 | }, 18 | "license": "Apache-2.0", 19 | "authors": [ 20 | { 21 | "name": "yunwuxin", 22 | "email": "448901948@qq.com" 23 | } 24 | ], 25 | "autoload": { 26 | "psr-4": { 27 | "yunwuxin\\": "src" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "think\\tests\\": "tests/" 33 | } 34 | }, 35 | "extra": { 36 | "think": { 37 | "config": { 38 | "mail": "src/config.php" 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | ./tests 18 | 19 | 20 | 21 | 22 | ./src/think 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Mail.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | namespace yunwuxin; 12 | 13 | use InvalidArgumentException; 14 | use Nette\Mail\SendmailMailer; 15 | use Nette\Mail\SmtpMailer; 16 | use think\helper\Arr; 17 | use think\Manager; 18 | use yunwuxin\mail\Mailable; 19 | use yunwuxin\mail\Mailer; 20 | 21 | /** 22 | * Class Mail 23 | * 24 | * @package yunwuxin 25 | * @method Mailer from($users) 26 | * @method Mailer to($users) 27 | * @method Mailer cc($users) 28 | * @method Mailer bcc($users) 29 | * @method send(Mailable $mailable) 30 | * @method sendNow(Mailable $mailable) 31 | * @method queue(Mailable $mailable) 32 | * @method array failures() 33 | */ 34 | class Mail extends Manager 35 | { 36 | public function getConfig(string $name = null, $default = null) 37 | { 38 | if (!is_null($name)) { 39 | return $this->app->config->get('mail.' . $name, $default); 40 | } 41 | 42 | return $this->app->config->get('mail'); 43 | } 44 | 45 | public function getTransportConfig($transport, $name = null, $default = null) 46 | { 47 | if ($config = $this->getConfig("transports.{$transport}")) { 48 | return Arr::get($config, $name, $default); 49 | } 50 | 51 | throw new InvalidArgumentException("Transport [$transport] not found."); 52 | } 53 | 54 | protected function createSmtpDriver($config) 55 | { 56 | return new SmtpMailer( 57 | host: $config['host'], 58 | port: $config['port'], 59 | username: $config['username'], 60 | password: $config['password'], 61 | encryption: $config['encryption'] ?? null, 62 | ); 63 | } 64 | 65 | protected function createSendmailDriver() 66 | { 67 | return new SendmailMailer(); 68 | } 69 | 70 | protected function resolveConfig(string $name) 71 | { 72 | return $this->getTransportConfig($name); 73 | } 74 | 75 | protected function createDriver(string $name) 76 | { 77 | $transport = parent::createDriver($name); 78 | 79 | /** @var Mailer $mailer */ 80 | $mailer = $this->app->invokeClass(Mailer::class, [$transport]); 81 | 82 | $mailer->from($this->app->config->get('mail.from')); 83 | 84 | return $mailer; 85 | } 86 | 87 | /** 88 | * 默认驱动 89 | * @return string|null 90 | */ 91 | public function getDefaultDriver() 92 | { 93 | return $this->getConfig('default'); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/config.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | return [ 13 | 'default' => 'smtp', 14 | 'transports' => [ 15 | 'smtp' => [ 16 | 'type' => 'smtp', 17 | 'host' => 'mail.example.com', 18 | 'port' => 25, 19 | 'encryption' => 'tls', 20 | 'username' => 'username', 21 | 'password' => 'password', 22 | ], 23 | 'sendmail' => [ 24 | 'type' => 'sendmail', 25 | ], 26 | ], 27 | 'from' => [ 28 | 'address' => 'example@example', 29 | 'name' => 'App Name', 30 | ], 31 | ]; 32 | -------------------------------------------------------------------------------- /src/facade/Mail.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace yunwuxin\mail; 13 | 14 | use think\Collection; 15 | 16 | /** 17 | * Class Mailable 18 | * @package yunwuxin\mail 19 | * 20 | * @property string $queue 21 | * @property integer $delay 22 | * @property string $connection 23 | */ 24 | class Mailable 25 | { 26 | /** @var array 发信人 */ 27 | public $from = []; 28 | 29 | /** @var array 收信人 */ 30 | public $to = []; 31 | 32 | /** @var array 抄送 */ 33 | public $cc = []; 34 | 35 | /** @var array 密送 */ 36 | public $bcc = []; 37 | 38 | /** @var array 回复人 */ 39 | public $replyTo = []; 40 | 41 | /** @var string 标题 */ 42 | public $subject; 43 | 44 | /** @var string 邮件内容(富文本) */ 45 | public $view; 46 | 47 | /** @var string 邮件内容(纯文本) */ 48 | public $textView; 49 | 50 | /** @var string 邮件内容(MarkDown) */ 51 | public $markdown; 52 | 53 | /** @var array 动态数据 */ 54 | public $viewData = []; 55 | 56 | /** @var array 附件(文件名) */ 57 | public $attachments = []; 58 | 59 | /** @var array 附件(数据) */ 60 | public $rawAttachments = []; 61 | 62 | public $callbacks = []; 63 | 64 | public $markdownCallback = null; 65 | 66 | protected function build() 67 | { 68 | //... 69 | } 70 | 71 | public function withMessage($callback) 72 | { 73 | $this->callbacks[] = $callback; 74 | 75 | return $this; 76 | } 77 | 78 | /** 79 | * 设置发信人 80 | * @param $address 81 | * @param null $name 82 | * @return Mailable 83 | */ 84 | public function from($address, $name = null) 85 | { 86 | return $this->setAddress($address, $name, 'from'); 87 | } 88 | 89 | /** 90 | * 设置收信人 91 | * @param $address 92 | * @param null $name 93 | * @return Mailable 94 | */ 95 | public function to($address, $name = null) 96 | { 97 | return $this->setAddress($address, $name, 'to'); 98 | } 99 | 100 | /** 101 | * 设置抄送 102 | * @param $address 103 | * @param null $name 104 | * @return Mailable 105 | */ 106 | public function cc($address, $name = null) 107 | { 108 | return $this->setAddress($address, $name, 'cc'); 109 | } 110 | 111 | /** 112 | * 设置密送 113 | * @param $address 114 | * @param null $name 115 | * @return Mailable 116 | */ 117 | public function bcc($address, $name = null) 118 | { 119 | return $this->setAddress($address, $name, 'bcc'); 120 | } 121 | 122 | /** 123 | * 设置回复人 124 | * @param $address 125 | * @param null $name 126 | * @return Mailable 127 | */ 128 | public function replyTo($address, $name = null) 129 | { 130 | return $this->setAddress($address, $name, 'replyTo'); 131 | } 132 | 133 | /** 134 | * 设置地址 135 | * 136 | * @param object|array|string $address 137 | * @param string|null $name 138 | * @param string $property 139 | * @return $this 140 | */ 141 | protected function setAddress($address, $name = null, $property = 'to') 142 | { 143 | if (is_object($address) && !$address instanceof Collection) { 144 | $address = [$address]; 145 | } 146 | 147 | if ($address instanceof Collection || is_array($address)) { 148 | foreach ($address as $user) { 149 | $user = $this->parseUser($user); 150 | 151 | $this->{$property}($user->email, isset($user->name) ? $user->name : null); 152 | } 153 | } else { 154 | $this->{$property}[] = compact('address', 'name'); 155 | } 156 | 157 | return $this; 158 | } 159 | 160 | /** 161 | * 格式化用户 162 | * @param $user 163 | * @return object 164 | */ 165 | protected function parseUser($user) 166 | { 167 | if (is_array($user)) { 168 | return (object) $user; 169 | } elseif (is_string($user)) { 170 | return (object) ['email' => $user]; 171 | } 172 | 173 | return $user; 174 | } 175 | 176 | /** 177 | * 设置标题 178 | * @param $subject 179 | * @return $this 180 | */ 181 | public function subject($subject) 182 | { 183 | $this->subject = $subject; 184 | 185 | return $this; 186 | } 187 | 188 | /** 189 | * 设置模板 190 | * @param $view 191 | * @param array $data 192 | * @return $this 193 | */ 194 | public function view($view, array $data = []) 195 | { 196 | $this->view = $view; 197 | $this->viewData = $data; 198 | 199 | return $this; 200 | } 201 | 202 | /** 203 | * 设置文本 204 | * @param $textView 205 | * @param array $data 206 | * @return $this 207 | */ 208 | public function text($textView, array $data = []) 209 | { 210 | $this->textView = $textView; 211 | $this->viewData = $data; 212 | 213 | return $this; 214 | } 215 | 216 | public function markdown($markdown, array $data = [], $callback = null) 217 | { 218 | $this->markdown = $markdown; 219 | $this->viewData = $data; 220 | $this->markdownCallback = $callback; 221 | 222 | return $this; 223 | } 224 | 225 | /** 226 | * 设置数据 227 | * @param $key 228 | * @param null $value 229 | * @return $this 230 | */ 231 | public function with($key, $value = null) 232 | { 233 | if (is_array($key)) { 234 | $this->viewData = array_merge($this->viewData, $key); 235 | } else { 236 | $this->viewData[$key] = $value; 237 | } 238 | 239 | return $this; 240 | } 241 | 242 | /** 243 | * 设置附件 244 | * @param $file 245 | * @return $this 246 | */ 247 | public function attach($file) 248 | { 249 | $this->attachments[] = $file; 250 | 251 | return $this; 252 | } 253 | 254 | /** 255 | * 设置附件 256 | * @param $data 257 | * @param $name 258 | * @return $this 259 | */ 260 | public function attachData($data, $name) 261 | { 262 | $this->rawAttachments[] = compact('data', 'name'); 263 | 264 | return $this; 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/mail/Mailer.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | namespace yunwuxin\mail; 12 | 13 | use Nette\Mail\Mailer as Transport; 14 | use think\App; 15 | use think\Queue; 16 | use think\queue\Queueable; 17 | use think\queue\ShouldQueue; 18 | 19 | class Mailer 20 | { 21 | 22 | /** @var Transport */ 23 | protected $transport; 24 | 25 | /** @var array 发信人 */ 26 | protected $from; 27 | 28 | /** @var array 收信人 */ 29 | protected $to = []; 30 | 31 | /** @var array 抄送 */ 32 | protected $cc = []; 33 | 34 | /** @var array 密送 */ 35 | protected $bcc = []; 36 | 37 | /** @var Queue */ 38 | protected $queue; 39 | 40 | /** @var App */ 41 | protected $app; 42 | 43 | public function __construct(Transport $transport, Queue $queue, App $app) 44 | { 45 | $this->transport = $transport; 46 | $this->queue = $queue; 47 | $this->app = $app; 48 | } 49 | 50 | public function from($users) 51 | { 52 | $this->from = $users; 53 | } 54 | 55 | public function to($users) 56 | { 57 | $this->to = $users; 58 | 59 | return $this; 60 | } 61 | 62 | public function cc($users) 63 | { 64 | $this->cc = $users; 65 | 66 | return $this; 67 | } 68 | 69 | public function bcc($users) 70 | { 71 | $this->bcc = $users; 72 | 73 | return $this; 74 | } 75 | 76 | /** 77 | * 发送邮件 78 | * @param Mailable $mailable 79 | */ 80 | public function send(Mailable $mailable) 81 | { 82 | if ($mailable instanceof ShouldQueue) { 83 | $this->queue($mailable); 84 | } else { 85 | $this->sendNow($mailable); 86 | } 87 | } 88 | 89 | /** 90 | * 发送邮件(立即发送) 91 | * @param Mailable $mailable 92 | */ 93 | public function sendNow(Mailable $mailable) 94 | { 95 | $message = $this->createMessage($mailable); 96 | 97 | if (isset($this->to['address'])) { 98 | $message->to($this->to['address'], $this->to['name']); 99 | } 100 | 101 | if (!empty($this->cc)) { 102 | $message->cc($this->cc); 103 | } 104 | if (!empty($this->bcc)) { 105 | $message->bcc($this->bcc); 106 | } 107 | 108 | $this->sendMessage($message); 109 | } 110 | 111 | /** 112 | * 推送至队列发送 113 | * @param Mailable|ShouldQueue $mailable 114 | */ 115 | public function queue($mailable) 116 | { 117 | $job = new SendQueuedMailable($mailable); 118 | 119 | if (in_array(Queueable::class, class_uses_recursive($mailable))) { 120 | $queue = $this->queue->connection($mailable->connection); 121 | if ($mailable->delay > 0) { 122 | $queue->later($mailable->delay, $job, '', $mailable->queue); 123 | } else { 124 | $queue->push($job, '', $mailable->queue); 125 | } 126 | } else { 127 | $this->queue->push($job); 128 | } 129 | } 130 | 131 | /** 132 | * 创建Message 133 | * @param Mailable $mailable 134 | * @return Message 135 | */ 136 | protected function createMessage(Mailable $mailable) 137 | { 138 | if (!empty($this->from['address'])) { 139 | $mailable->from($this->from['address'], $this->from['name']); 140 | } 141 | 142 | return $this->app->invokeClass(Message::class, [$mailable]); 143 | } 144 | 145 | /** 146 | * 发送Message 147 | * @param Message $message 148 | */ 149 | protected function sendMessage($message) 150 | { 151 | $this->transport->send($message->getMail()); 152 | } 153 | 154 | } 155 | -------------------------------------------------------------------------------- /src/mail/Markdown.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace yunwuxin\mail; 13 | 14 | use Closure; 15 | use ReflectionClass; 16 | use ReflectionProperty; 17 | use think\App; 18 | use think\helper\Str; 19 | use think\View; 20 | use think\view\driver\Twig; 21 | use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles; 22 | use Twig\TwigFilter; 23 | use yunwuxin\mail\twig\TokenParser\Component; 24 | 25 | /** 26 | * Class Message 27 | * @package yunwuxin\mail 28 | * 29 | * @mixin \Nette\Mail\Message 30 | */ 31 | class Message 32 | { 33 | /** @var \Nette\Mail\Message */ 34 | protected $mail; 35 | 36 | /** @var View */ 37 | protected $view; 38 | 39 | /** @var App */ 40 | protected $app; 41 | 42 | public function __construct(Mailable $mailable, View $view, App $app) 43 | { 44 | $this->mail = new \Nette\Mail\Message(); 45 | $this->view = $view; 46 | $this->app = $app; 47 | 48 | $this->build($mailable); 49 | } 50 | 51 | protected function build(Mailable $mailable) 52 | { 53 | $this->app->invoke([$mailable, 'build'], [], true); 54 | 55 | $this->buildContent($mailable) 56 | ->buildFrom($mailable) 57 | ->buildRecipients($mailable) 58 | ->buildSubject($mailable) 59 | ->runCallbacks($mailable) 60 | ->buildAttachments($mailable); 61 | } 62 | 63 | /** 64 | * 构造数据 65 | * @param Mailable $mailable 66 | * @return array 67 | */ 68 | protected function buildViewData(Mailable $mailable) 69 | { 70 | $data = $mailable->viewData; 71 | 72 | foreach ((new ReflectionClass($mailable))->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { 73 | if ($property->getDeclaringClass()->getName() !== Mailable::class) { 74 | $data[$property->getName()] = $property->getValue($mailable); 75 | } 76 | } 77 | 78 | $data['message'] = $this; 79 | 80 | return $data; 81 | } 82 | 83 | /** 84 | * 添加内容 85 | * @param Mailable $mailable 86 | * @return $this 87 | */ 88 | protected function buildContent(Mailable $mailable) 89 | { 90 | $data = $this->buildViewData($mailable); 91 | 92 | if (isset($mailable->markdown)) { 93 | 94 | $html = $this->parseDown($mailable->markdown, $data, $mailable->markdownCallback); 95 | 96 | $css = $this->app->config->get('mail.css', __DIR__ . '/resource/css/default.css'); 97 | 98 | $html = (new CssToInlineStyles())->convert($html, file_get_contents($css)); 99 | 100 | $this->setHtmlBody($html); 101 | } else { 102 | if (isset($mailable->view)) { 103 | $this->setHtmlBody($this->fetchView($mailable->view, $data)); 104 | } elseif (isset($mailable->textView)) { 105 | $method = isset($mailable->view) ? 'addPart' : 'setBody'; 106 | 107 | $this->$method($this->fetchView($mailable->textView, $data)); 108 | } 109 | } 110 | return $this; 111 | } 112 | 113 | /** 114 | * 解析markdown 115 | * @param $view 116 | * @param $data 117 | * @param Closure|null $callback 118 | * @return string 119 | */ 120 | protected function parseDown($view, $data, Closure $callback = null) 121 | { 122 | /** @var Twig $twig */ 123 | $twig = $this->view->engine('twig'); 124 | 125 | $parser = new Markdown(); 126 | $parser->html5 = true; 127 | 128 | $twig->getTwig()->addFilter(new TwigFilter('maildown', function ($content) use ($parser) { 129 | $content = preg_replace('/^[^\S\n]+/m', '', $content); 130 | return $parser->parse($content); 131 | })); 132 | 133 | $twig->getTwig()->addTokenParser(new Component()); 134 | 135 | $twig->getLoader()->addPath(__DIR__ . '/resource/view', 'mail'); 136 | 137 | if ($callback) { 138 | $callback($twig); 139 | } 140 | 141 | $content = $twig->getTwig()->render($view . '.twig', $data); 142 | 143 | //清理 144 | $this->view->forgetDriver('twig'); 145 | 146 | return $content; 147 | } 148 | 149 | /** 150 | * 调用模板引擎渲染模板 151 | * @param $view 152 | * @param $data 153 | * @return string 154 | */ 155 | protected function fetchView($view, $data) 156 | { 157 | return $this->view->fetch($view, $data); 158 | } 159 | 160 | /** 161 | * 构造发信人 162 | * @param Mailable $mailable 163 | * @return $this 164 | */ 165 | protected function buildFrom(Mailable $mailable) 166 | { 167 | if (!empty($mailable->from)) { 168 | $this->from($mailable->from[0]['address'], $mailable->from[0]['name']); 169 | } 170 | return $this; 171 | } 172 | 173 | /** 174 | * 构造收信人 175 | * @param Mailable $mailable 176 | * @return $this 177 | */ 178 | protected function buildRecipients(Mailable $mailable) 179 | { 180 | foreach (['to', 'cc', 'bcc', 'replyTo'] as $type) { 181 | foreach ($mailable->{$type} as $recipient) { 182 | $this->{$type}($recipient['address'], $recipient['name']); 183 | } 184 | } 185 | 186 | return $this; 187 | } 188 | 189 | /** 190 | * 构造标题 191 | * @param Mailable $mailable 192 | * @return $this 193 | */ 194 | protected function buildSubject(Mailable $mailable) 195 | { 196 | if ($mailable->subject) { 197 | $this->subject($mailable->subject); 198 | } else { 199 | $this->subject(Str::title(Str::snake(class_basename($mailable), ' '))); 200 | } 201 | 202 | return $this; 203 | } 204 | 205 | /** 206 | * 构造附件 207 | * @param Mailable $mailable 208 | * @return $this 209 | */ 210 | protected function buildAttachments(Mailable $mailable) 211 | { 212 | foreach ($mailable->attachments as $attachment) { 213 | $this->attach($attachment); 214 | } 215 | 216 | foreach ($mailable->rawAttachments as $attachment) { 217 | $this->attach($attachment['name'], $attachment['data']); 218 | } 219 | 220 | return $this; 221 | } 222 | 223 | /** 224 | * 执行回调 225 | * 226 | * @param Mailable $mailable 227 | * @return $this 228 | */ 229 | protected function runCallbacks(Mailable $mailable) 230 | { 231 | foreach ($mailable->callbacks as $callback) { 232 | $callback($this->mail); 233 | } 234 | 235 | return $this; 236 | } 237 | 238 | /** 239 | * Add a "from" address to the message. 240 | * 241 | * @param string|array $address 242 | * @param string|null $name 243 | * @return $this 244 | */ 245 | public function from($address, $name = null) 246 | { 247 | $this->mail->setFrom($address, $name); 248 | 249 | return $this; 250 | } 251 | 252 | /** 253 | * Set the "return path" of the message. 254 | * 255 | * @param string $address 256 | * @return $this 257 | */ 258 | public function returnPath($address) 259 | { 260 | $this->mail->setReturnPath($address); 261 | 262 | return $this; 263 | } 264 | 265 | /** 266 | * Add a recipient to the message. 267 | * 268 | * @param string|array $address 269 | * @param string|null $name 270 | * @return $this 271 | */ 272 | public function to($address, $name = null) 273 | { 274 | $this->mail->addTo($address, $name); 275 | 276 | return $this; 277 | } 278 | 279 | /** 280 | * Add a carbon copy to the message. 281 | * 282 | * @param string|array $address 283 | * @param string|null $name 284 | * @return $this 285 | */ 286 | public function cc($address, $name = null) 287 | { 288 | $this->mail->addCc($address, $name); 289 | return $this; 290 | } 291 | 292 | /** 293 | * Add a blind carbon copy to the message. 294 | * 295 | * @param string|array $address 296 | * @param string|null $name 297 | * @return $this 298 | */ 299 | public function bcc($address, $name = null) 300 | { 301 | $this->mail->addBcc($address, $name); 302 | return $this; 303 | } 304 | 305 | /** 306 | * Add a reply to address to the message. 307 | * 308 | * @param string|array $address 309 | * @param string|null $name 310 | * @return $this 311 | */ 312 | public function replyTo($address, $name = null) 313 | { 314 | $this->mail->addReplyTo($address, $name); 315 | return $this; 316 | } 317 | 318 | /** 319 | * Set the subject of the message. 320 | * 321 | * @param string $subject 322 | * @return $this 323 | */ 324 | public function subject($subject) 325 | { 326 | $this->mail->setSubject($subject); 327 | return $this; 328 | } 329 | 330 | /** 331 | * Set the message priority level. 332 | * 333 | * @param int $level 334 | * @return $this 335 | */ 336 | public function priority($level) 337 | { 338 | $this->mail->setPriority($level); 339 | return $this; 340 | } 341 | 342 | /** 343 | * Attach a file to the message. 344 | * 345 | * @param string $file 346 | * @return $this 347 | */ 348 | public function attach($file, $content = null, $contentType = null) 349 | { 350 | $this->mail->addAttachment($file, $content, $contentType); 351 | return $this; 352 | } 353 | 354 | /** 355 | * Embed a file in the message and get the CID. 356 | * 357 | * @param string $file 358 | */ 359 | public function embed($file, $content = null, $contentType = null) 360 | { 361 | return $this->mail->addEmbeddedFile($file, $content, $contentType); 362 | } 363 | 364 | /** 365 | * Get the underlying Swift Message instance. 366 | * 367 | * @return \Nette\Mail\Message 368 | */ 369 | public function getMail() 370 | { 371 | return $this->mail; 372 | } 373 | 374 | /** 375 | * Dynamically pass missing methods to the Swift instance. 376 | * 377 | * @param string $method 378 | * @param array $parameters 379 | * @return mixed 380 | */ 381 | public function __call($method, $parameters) 382 | { 383 | $callable = [$this->mail, $method]; 384 | 385 | return call_user_func_array($callable, $parameters); 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /src/mail/SendQueuedMailable.php: -------------------------------------------------------------------------------- 1 | 10 | // +---------------------------------------------------------------------- 11 | 12 | namespace yunwuxin\mail; 13 | 14 | use yunwuxin\Mail; 15 | 16 | class SendQueuedMailable 17 | { 18 | /** @var Mailable */ 19 | protected $mailable; 20 | 21 | public function __construct(Mailable $mailable) 22 | { 23 | $this->mailable = $mailable; 24 | } 25 | 26 | public function handle(Mail $mail) 27 | { 28 | $mail->sendNow($this->mailable); 29 | } 30 | 31 | public function __clone() 32 | { 33 | $this->mailable = clone $this->mailable; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/mail/resource/css/default.css: -------------------------------------------------------------------------------- 1 | /* Base */ 2 | 3 | body, body *:not(html):not(style):not(br):not(tr):not(code) { 4 | font-family: "微软雅黑", "Microsoft Yahei", Avenir, Helvetica, sans-serif; 5 | box-sizing: border-box; 6 | } 7 | 8 | body { 9 | background-color: #f5f8fa; 10 | color: #74787E; 11 | height: 100%; 12 | hyphens: auto; 13 | line-height: 1.4; 14 | margin: 0; 15 | -moz-hyphens: auto; 16 | -ms-word-break: break-all; 17 | width: 100% !important; 18 | -webkit-hyphens: auto; 19 | -webkit-text-size-adjust: none; 20 | word-break: break-all; 21 | word-break: break-word; 22 | font-size: 14px; 23 | } 24 | 25 | p, 26 | ul, 27 | ol, 28 | blockquote { 29 | line-height: 1.4; 30 | text-align: left; 31 | } 32 | 33 | a { 34 | color: #3869D4; 35 | } 36 | 37 | a img { 38 | border: none; 39 | } 40 | 41 | /* Typography */ 42 | 43 | h1 { 44 | color: #2F3133; 45 | font-size: 19px; 46 | font-weight: bold; 47 | margin-top: 0; 48 | text-align: left; 49 | } 50 | 51 | h2 { 52 | color: #2F3133; 53 | font-size: 16px; 54 | font-weight: bold; 55 | margin-top: 0; 56 | text-align: left; 57 | } 58 | 59 | h3 { 60 | color: #2F3133; 61 | font-size: 14px; 62 | font-weight: bold; 63 | margin-top: 0; 64 | text-align: left; 65 | } 66 | 67 | p { 68 | color: #74787E; 69 | font-size: 14px; 70 | line-height: 1.4; 71 | margin-top: 0; 72 | text-align: left; 73 | } 74 | 75 | p.sub { 76 | font-size: 12px; 77 | } 78 | 79 | img { 80 | max-width: 100%; 81 | } 82 | 83 | /* Layout */ 84 | 85 | .wrapper { 86 | background-color: #f5f8fa; 87 | margin: 0; 88 | padding: 0; 89 | width: 100%; 90 | -premailer-cellpadding: 0; 91 | -premailer-cellspacing: 0; 92 | -premailer-width: 100%; 93 | } 94 | 95 | .content { 96 | margin: 0; 97 | padding: 0; 98 | width: 100%; 99 | -premailer-cellpadding: 0; 100 | -premailer-cellspacing: 0; 101 | -premailer-width: 100%; 102 | } 103 | 104 | /* Header */ 105 | 106 | .header { 107 | padding: 25px 0; 108 | text-align: center; 109 | } 110 | 111 | .header a { 112 | color: #bbbfc3; 113 | font-size: 19px; 114 | font-weight: bold; 115 | text-decoration: none; 116 | text-shadow: 0 1px 0 white; 117 | } 118 | 119 | /* Body */ 120 | 121 | .body { 122 | background-color: #FFFFFF; 123 | border-bottom: 1px solid #EDEFF2; 124 | border-top: 1px solid #EDEFF2; 125 | margin: 0; 126 | padding: 0; 127 | width: 100%; 128 | -premailer-cellpadding: 0; 129 | -premailer-cellspacing: 0; 130 | -premailer-width: 100%; 131 | } 132 | 133 | .inner-body { 134 | background-color: #FFFFFF; 135 | margin: 0 auto; 136 | padding: 0; 137 | width: 570px; 138 | -premailer-cellpadding: 0; 139 | -premailer-cellspacing: 0; 140 | -premailer-width: 570px; 141 | } 142 | 143 | /* Subcopy */ 144 | 145 | .subcopy { 146 | border-top: 1px solid #EDEFF2; 147 | margin-top: 25px; 148 | padding-top: 25px; 149 | } 150 | 151 | .subcopy p { 152 | font-size: 12px; 153 | } 154 | 155 | /* Footer */ 156 | 157 | .footer { 158 | margin: 0 auto; 159 | padding: 0; 160 | text-align: center; 161 | width: 570px; 162 | -premailer-cellpadding: 0; 163 | -premailer-cellspacing: 0; 164 | -premailer-width: 570px; 165 | } 166 | 167 | .footer p { 168 | color: #AEAEAE; 169 | font-size: 12px; 170 | text-align: center; 171 | } 172 | 173 | /* Tables */ 174 | 175 | .table table { 176 | margin: 30px auto; 177 | width: 100%; 178 | -premailer-cellpadding: 0; 179 | -premailer-cellspacing: 0; 180 | -premailer-width: 100%; 181 | } 182 | 183 | .table th { 184 | border-bottom: 1px solid #EDEFF2; 185 | padding-bottom: 8px; 186 | } 187 | 188 | .table td { 189 | color: #74787E; 190 | font-size: 15px; 191 | line-height: 18px; 192 | padding: 10px 0; 193 | } 194 | 195 | .content-cell { 196 | padding: 35px; 197 | } 198 | 199 | /* Buttons */ 200 | 201 | .action { 202 | margin: 30px auto; 203 | padding: 0; 204 | text-align: center; 205 | width: 100%; 206 | -premailer-cellpadding: 0; 207 | -premailer-cellspacing: 0; 208 | -premailer-width: 100%; 209 | } 210 | 211 | .button { 212 | border-radius: 3px; 213 | box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); 214 | color: #FFF; 215 | display: inline-block; 216 | text-decoration: none; 217 | -webkit-text-size-adjust: none; 218 | } 219 | 220 | .button-blue { 221 | background-color: #3097D1; 222 | border-top: 10px solid #3097D1; 223 | border-right: 18px solid #3097D1; 224 | border-bottom: 10px solid #3097D1; 225 | border-left: 18px solid #3097D1; 226 | } 227 | 228 | .button-green { 229 | background-color: #2ab27b; 230 | border-top: 10px solid #2ab27b; 231 | border-right: 18px solid #2ab27b; 232 | border-bottom: 10px solid #2ab27b; 233 | border-left: 18px solid #2ab27b; 234 | } 235 | 236 | .button-red { 237 | background-color: #bf5329; 238 | border-top: 10px solid #bf5329; 239 | border-right: 18px solid #bf5329; 240 | border-bottom: 10px solid #bf5329; 241 | border-left: 18px solid #bf5329; 242 | } 243 | 244 | .code { 245 | padding: 10px 18px 10px 18px; 246 | border-radius: 3px; 247 | text-align: center; 248 | text-decoration: none; 249 | background-color: #ecf4fb; 250 | color: #4581E9; 251 | font-size: 20px; 252 | font-weight: 700; 253 | letter-spacing: 2px; 254 | margin: 0; 255 | white-space: nowrap; 256 | line-height: 1.7; 257 | } 258 | 259 | /* Panels */ 260 | 261 | .panel { 262 | margin: 0 0 21px; 263 | } 264 | 265 | .panel-content { 266 | background-color: #EDEFF2; 267 | padding: 16px; 268 | } 269 | 270 | .panel-item { 271 | padding: 0; 272 | } 273 | 274 | .panel-item p:last-of-type { 275 | margin-bottom: 0; 276 | padding-bottom: 0; 277 | } 278 | 279 | /* Promotions */ 280 | 281 | .promotion { 282 | background-color: #FFFFFF; 283 | border: 2px dashed #9BA2AB; 284 | margin: 0; 285 | margin-bottom: 25px; 286 | margin-top: 25px; 287 | padding: 24px; 288 | width: 100%; 289 | -premailer-cellpadding: 0; 290 | -premailer-cellspacing: 0; 291 | -premailer-width: 100%; 292 | } 293 | 294 | .promotion h1 { 295 | text-align: center; 296 | } 297 | 298 | .promotion p { 299 | font-size: 15px; 300 | text-align: center; 301 | } 302 | -------------------------------------------------------------------------------- /src/mail/resource/view/button.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | 20 |
4 | 5 | 6 | 16 | 17 |
7 | 8 | 9 | 13 | 14 |
10 | {{ slot }} 12 |
15 |
18 |
21 | -------------------------------------------------------------------------------- /src/mail/resource/view/code.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | 19 |
4 | 5 | 6 | 15 | 16 |
7 | 8 | 9 | 12 | 13 |
10 |
{{ slot }}
11 |
14 |
17 |
20 | -------------------------------------------------------------------------------- /src/mail/resource/view/footer.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/mail/resource/view/header.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ slot }} 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/mail/resource/view/layout.twig: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 26 | 27 | 28 | 29 | 56 | 57 |
30 | 31 | {% block header %} 32 | {% endblock %} 33 | 34 | 35 | 36 | 50 | 51 | 52 | {% block footer %} 53 | {% endblock %} 54 |
37 | 38 | 39 | 40 | 47 | 48 |
41 | {% block body %} 42 | {% endblock %} 43 | 44 | {% block subcopy %} 45 | {% endblock %} 46 |
49 |
55 |
58 | 59 | 60 | -------------------------------------------------------------------------------- /src/mail/resource/view/message.twig: -------------------------------------------------------------------------------- 1 | {% extends "@mail/layout.twig" %} 2 | 3 | {% block header %} 4 | {% component "@mail/header" with { url: config('app.url') } %} 5 | {{ config('app.name') }} 6 | {% endcomponent %} 7 | {% endblock %} 8 | 9 | {% block body %} 10 | {{ slot|maildown|raw }} 11 | {% endblock %} 12 | 13 | {% block footer %} 14 | {% component "@mail/footer" %} 15 | © {{ 'now'|date('Y') }} {{ config('app.name') }}. All rights reserved. 16 | {% endcomponent %} 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /src/mail/resource/view/panel.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 |
4 | 5 | 6 | 9 | 10 |
7 | {{ slot|maildown|raw }} 8 |
11 |
14 | -------------------------------------------------------------------------------- /src/mail/resource/view/promotion.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 |
4 | {{ slot|maildown|raw }} 5 |
8 | -------------------------------------------------------------------------------- /src/mail/resource/view/subcopy.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 |
4 | {{ slot|maildown|raw }} 5 |
8 | -------------------------------------------------------------------------------- /src/mail/resource/view/table.twig: -------------------------------------------------------------------------------- 1 |
2 | {{ slot|maildown|raw }} 3 |
4 | -------------------------------------------------------------------------------- /src/mail/twig/Node/Component.php: -------------------------------------------------------------------------------- 1 | $expr, 'body' => $body]; 14 | if (null !== $variables) { 15 | $nodes['variables'] = $variables; 16 | } 17 | 18 | parent::__construct($nodes, ['only' => (bool) $only, 'ignore_missing' => (bool) $ignoreMissing], $lineno, $tag); 19 | } 20 | 21 | public function compile(Compiler $compiler) 22 | { 23 | $compiler->addDebugInfo($this); 24 | 25 | $this->addTemplateArguments($compiler); 26 | 27 | if ($this->getAttribute('ignore_missing')) { 28 | 29 | $template = $compiler->getVarName(); 30 | 31 | $compiler 32 | ->write(sprintf("$%s = null;\n", $template)) 33 | ->write("try {\n") 34 | ->indent() 35 | ->write(sprintf('$%s = ', $template)); 36 | 37 | $this->addGetTemplate($compiler); 38 | 39 | $compiler 40 | ->raw(";\n") 41 | ->outdent() 42 | ->write("} catch (LoaderError \$e) {\n") 43 | ->indent() 44 | ->write("// ignore missing template\n") 45 | ->outdent() 46 | ->write("}\n") 47 | ->write(sprintf("if ($%s) {\n", $template)) 48 | ->indent() 49 | ->write(sprintf('$%s->display($context', $template)); 50 | 51 | $compiler 52 | ->raw(");\n") 53 | ->outdent() 54 | ->write("}\n"); 55 | } else { 56 | $this->addGetTemplate($compiler); 57 | $compiler->raw('->display($context);'); 58 | $compiler->raw("\n"); 59 | } 60 | } 61 | 62 | protected function addGetTemplate(Compiler $compiler) 63 | { 64 | $compiler 65 | ->write('$this->loadTemplate(') 66 | ->subcompile($this->getNode('expr')) 67 | ->raw('.".twig", ') 68 | ->repr($this->getTemplateName()) 69 | ->raw(', ') 70 | ->repr($this->getTemplateLine()) 71 | ->raw(')'); 72 | } 73 | 74 | protected function addTemplateArguments(Compiler $compiler) 75 | { 76 | if (!$this->hasNode('variables')) { 77 | if (false !== $this->getAttribute('only')) { 78 | $compiler->write('$context = [];'); 79 | } 80 | } elseif (false === $this->getAttribute('only')) { 81 | $compiler 82 | ->write('$context = twig_array_merge($context, ') 83 | ->subcompile($this->getNode('variables')) 84 | ->raw(');'); 85 | } else { 86 | $compiler->write('$context = twig_to_array('); 87 | $compiler->subcompile($this->getNode('variables')); 88 | $compiler->raw(');'); 89 | } 90 | $compiler->raw("\n"); 91 | 92 | $compiler 93 | ->write("ob_start();\n") 94 | ->subcompile($this->getNode('body')) 95 | ->write('$context["slot"] = ob_get_clean();') 96 | ->raw("\n"); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/mail/twig/TokenParser/Component.php: -------------------------------------------------------------------------------- 1 | parser->getExpressionParser()->parseExpression(); 20 | 21 | $stream = $this->parser->getStream(); 22 | 23 | list($variables, $only, $ignoreMissing) = $this->parseArguments($stream); 24 | 25 | $stream->expect(Token::BLOCK_END_TYPE); 26 | $body = $this->parser->subparse([$this, 'decideBlockEnd'], true); 27 | $stream->expect(Token::BLOCK_END_TYPE); 28 | 29 | return new ComponentNode($body, $expr, $variables, $only, $ignoreMissing, $token->getLine(), $this->getTag()); 30 | } 31 | 32 | public function decideBlockEnd(Token $token) 33 | { 34 | return $token->test('endcomponent'); 35 | } 36 | 37 | protected function parseArguments(TokenStream $stream) 38 | { 39 | $ignoreMissing = false; 40 | if ($stream->nextIf(Token::NAME_TYPE, 'ignore')) { 41 | $stream->expect(Token::NAME_TYPE, 'missing'); 42 | 43 | $ignoreMissing = true; 44 | } 45 | 46 | $variables = null; 47 | if ($stream->nextIf(Token::NAME_TYPE, 'with')) { 48 | $variables = $this->parser->getExpressionParser()->parseExpression(); 49 | } 50 | 51 | $only = false; 52 | if ($stream->nextIf(Token::NAME_TYPE, 'only')) { 53 | $only = true; 54 | } 55 | 56 | return [$variables, $only, $ignoreMissing]; 57 | } 58 | 59 | public function getTag() 60 | { 61 | return 'component'; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/TwigComponentTest.php: -------------------------------------------------------------------------------- 1 | app = m::mock(App::class)->makePartial(); 29 | 30 | Container::setInstance($this->app); 31 | $this->app->shouldReceive('make')->with(App::class)->andReturn($this->app); 32 | $this->app->shouldReceive('isDebug')->andReturnTrue(); 33 | 34 | $this->view = new View($this->app); 35 | 36 | $this->twig = $this->view->engine('twig'); 37 | $parser = new Markdown(); 38 | $parser->html5 = true; 39 | $this->twig->getTwig()->addFilter(new TwigFilter('markdown', function ($content) use ($parser) { 40 | $content = preg_replace('/^[^\S\n]+/m', '', $content); 41 | return $parser->parse($content); 42 | })); 43 | 44 | $this->twig->getTwig()->addTokenParser(new Component()); 45 | 46 | $this->twig->getLoader()->addPath(__DIR__ . '/../src/mail/resource/view', 'mail'); 47 | 48 | $this->twig->getLoader()->addPath(__DIR__ . '/fixtures', 'fixtures'); 49 | } 50 | 51 | protected function tearDown(): void 52 | { 53 | m::close(); 54 | } 55 | 56 | public function testNormalComponent() 57 | { 58 | echo $this->twig->getTwig()->render('@fixtures/normal.twig', 59 | ['level' => 'aaa', 60 | 'subject' => 'bbbb', 61 | 'greeting' => 'ccc', 62 | 'introLines' => ['aaa'], 63 | 'outroLines' => ['aaa'], 64 | 'actionText' => null, 65 | 'actionUrl' => null, 66 | ]); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |