Emailsubscribe.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. <?php
  2. namespace app\index\controller;
  3. use app\common\controller\Frontend;
  4. use app\common\model\EmailSubscribe as EmailSubscribeModel;
  5. use app\common\model\EmailSubscribeLog as EmailSubscribeLogModel;
  6. use app\common\model\EmailTemplate as EmailTemplateModel;
  7. use think\facade\Cache;
  8. use think\facade\Db;
  9. use think\facade\Validate;
  10. class Emailsubscribe extends Frontend
  11. {
  12. protected $noNeedLogin = ['submit', 'unsubscribe'];
  13. protected $noNeedRight = ['*'];
  14. protected function frontendUrl(string $route, array $params = [], bool $suffix = true, bool $domain = true): string
  15. {
  16. $url = (string) url($route, $params, $suffix, $domain);
  17. return function_exists('cms_lang_url') ? cms_lang_url($url) : $url;
  18. }
  19. protected function frontendCurrentUrl(): string
  20. {
  21. $url = (string)$this->request->url(true);
  22. return function_exists('cms_lang_url') ? cms_lang_url($url) : $url;
  23. }
  24. protected function buildUnsubscribeToken(string $email, string $lang): string
  25. {
  26. return md5($email . '|' . $lang . '|' . uniqid('', true) . '|' . mt_rand(100000, 999999));
  27. }
  28. protected function buildUnsubscribeUrl(EmailSubscribeModel $subscribe): string
  29. {
  30. return $this->frontendUrl('index/emailsubscribe/unsubscribe', ['token' => (string)$subscribe->unsubscribe_token], true, true);
  31. }
  32. protected function normalizeLang(string $lang): string
  33. {
  34. $lang = strtolower(trim($lang));
  35. if ($lang !== '') {
  36. return $lang;
  37. }
  38. if (function_exists('cms_current_region_lang')) {
  39. $resolved = strtolower(trim((string)cms_current_region_lang('cn')));
  40. if ($resolved !== '') {
  41. return $resolved;
  42. }
  43. }
  44. return 'cn';
  45. }
  46. protected function sendSubscribeEmail(string $email, string $subject, string $content): array
  47. {
  48. try {
  49. $config = get_addon_config('phpmailer');
  50. if (!$config) {
  51. return [false, 'NO_CONFIG', '未找到 phpmailer 插件配置'];
  52. }
  53. $mailer = new \addons\phpmailer\library\Mailer($config);
  54. $result = $mailer->to($email)->subject($subject)->message($content, true)->send();
  55. if ($result) {
  56. return [true, 'SUCCESS', '邮件发送成功'];
  57. }
  58. return [false, 'FAIL', $mailer->getError() ?: '邮件发送失败'];
  59. } catch (\Throwable $e) {
  60. return [false, 'EXCEPTION', $e->getMessage()];
  61. }
  62. }
  63. protected function buildMailContext(array $data): array
  64. {
  65. $context = [];
  66. foreach ($data as $key => $value) {
  67. $context[$key] = is_scalar($value) || $value === null ? (string)$value : json_encode($value, JSON_UNESCAPED_UNICODE);
  68. }
  69. return $context;
  70. }
  71. protected function renderTemplateContent(string $content, array $context): string
  72. {
  73. $replace = [];
  74. foreach ($context as $key => $value) {
  75. $replace['{{' . $key . '}}'] = (string)$value;
  76. $replace['{{ ' . $key . ' }}'] = (string)$value;
  77. }
  78. return strtr($content, $replace);
  79. }
  80. protected function getTemplateByType(string $type, string $lang): ?EmailTemplateModel
  81. {
  82. $lang = $this->normalizeLang($lang);
  83. $template = EmailTemplateModel::where(['type' => $type, 'lang' => $lang, 'status' => 1])
  84. ->order('listorder', 'asc')
  85. ->order('id', 'asc')
  86. ->find();
  87. if ($template) {
  88. return $template;
  89. }
  90. if ($lang !== 'cn') {
  91. return EmailTemplateModel::where(['type' => $type, 'lang' => 'cn', 'status' => 1])
  92. ->order('listorder', 'asc')
  93. ->order('id', 'asc')
  94. ->find();
  95. }
  96. return null;
  97. }
  98. protected function buildMailByType(string $type, string $lang, array $context = []): array
  99. {
  100. $template = $this->getTemplateByType($type, $lang);
  101. if ($template) {
  102. return [
  103. 'subject' => $this->renderTemplateContent((string)$template->subject, $context),
  104. 'content' => $this->renderTemplateContent((string)$template->content, $context),
  105. 'template_id' => (int)$template->id,
  106. 'template_code' => (string)$template->code,
  107. ];
  108. }
  109. $fallback = $this->buildAutoMail($lang, $type);
  110. return [
  111. 'subject' => $this->renderTemplateContent((string)$fallback['subject'], $context),
  112. 'content' => $this->renderTemplateContent((string)$fallback['content'], $context),
  113. 'template_id' => 0,
  114. 'template_code' => '',
  115. ];
  116. }
  117. protected function buildAutoMail(string $lang, string $type = 'subscribe_auto'): array
  118. {
  119. $isCn = in_array($lang, ['cn', 'zh', 'zh-cn'], true);
  120. if ($isCn) {
  121. return [
  122. 'subject' => '感谢订阅 XTU 最新动态',
  123. 'content' => '您已成功订阅 XTU 的新品发布与品牌动态邮件通知,后续如有更新我们会第一时间通过邮件发送给您。',
  124. ];
  125. }
  126. return [
  127. 'subject' => 'Thanks for subscribing to XTU updates',
  128. 'content' => 'You have successfully subscribed to XTU news, new arrivals, and brand updates. We will email you when new updates are available.',
  129. ];
  130. }
  131. public function submit()
  132. {
  133. if (!$this->request->isPost()) {
  134. $this->error('请求方式错误');
  135. }
  136. $email = trim((string)$this->request->post('email/s', ''));
  137. $lang = $this->normalizeLang((string)$this->request->post('lang/s', ''));
  138. $sourcePage = trim((string)$this->request->post('source_page/s', $this->frontendCurrentUrl()));
  139. $sourceKey = trim((string)$this->request->post('source_key/s', 'footer'));
  140. $remark = trim((string)$this->request->post('remark/s', ''));
  141. $privacyAgree = (int)$this->request->post('privacy_agree/d', 0);
  142. $now = time();
  143. $ip = (string)$this->request->ip();
  144. $userAgent = substr((string)$this->request->server('HTTP_USER_AGENT'), 0, 255);
  145. if ($email === '' || !Validate::is($email, 'email')) {
  146. $this->error('请输入正确的邮箱地址');
  147. }
  148. if ($privacyAgree !== 1) {
  149. $this->error('请先勾选并同意隐私政策');
  150. }
  151. $ipLimitWindowSeconds = 60;
  152. $ipLimitWindowMax = max(0, (int) config('site.sms_rpm', 5));
  153. $ipLimitDailyMax = max(0, (int) config('site.sms_drl', 10));
  154. $dayKey = date('Ymd', $now);
  155. $ipWindowKey = 'email_subscribe_ip_window_' . md5($ip);
  156. $ipDailyKey = 'email_subscribe_ip_daily_' . md5($ip . '_' . $dayKey);
  157. $ipWindowHits = (int) Cache::get($ipWindowKey, 0);
  158. if ($ipLimitWindowMax > 0 && $ipWindowHits >= $ipLimitWindowMax) {
  159. $this->error('提交过于频繁,请稍后再试');
  160. }
  161. $ipDailyHits = (int) Cache::get($ipDailyKey, 0);
  162. if ($ipLimitDailyMax > 0 && $ipDailyHits >= $ipLimitDailyMax) {
  163. $this->error('今日提交次数已达上限,请明天再试');
  164. }
  165. if ($ipLimitWindowMax > 0) {
  166. Cache::set($ipWindowKey, $ipWindowHits + 1, $ipLimitWindowSeconds);
  167. }
  168. if ($ipLimitDailyMax > 0) {
  169. Cache::set($ipDailyKey, $ipDailyHits + 1, 86400);
  170. }
  171. $existing = EmailSubscribeModel::where(['email' => $email, 'lang' => $lang])->find();
  172. if ($existing && (int)$existing->status === 1) {
  173. $this->success('该邮箱已订阅,请勿重复提交', null, [
  174. 'subscribe_id' => $existing->id,
  175. 'duplicate' => 1,
  176. ]);
  177. }
  178. $sendResult = false;
  179. $responseCode = '';
  180. $responseMsg = '';
  181. $subscribe = $existing ?: new EmailSubscribeModel();
  182. Db::startTrans();
  183. try {
  184. $unsubscribeToken = (string)($existing->unsubscribe_token ?? '');
  185. if ($unsubscribeToken === '') {
  186. $unsubscribeToken = $this->buildUnsubscribeToken($email, $lang);
  187. }
  188. $subscribe->save([
  189. 'email' => $email,
  190. 'lang' => $lang,
  191. 'source_page' => $sourcePage,
  192. 'source_key' => $sourceKey !== '' ? $sourceKey : 'footer',
  193. 'status' => 1,
  194. 'confirm_status' => 0,
  195. 'last_send_status' => 0,
  196. 'last_send_time' => 0,
  197. 'ip' => $ip,
  198. 'user_agent' => $userAgent,
  199. 'remark' => $remark,
  200. 'privacy_agree' => 1,
  201. 'privacy_agree_time' => $now,
  202. 'privacy_agree_ip' => $ip,
  203. 'unsubscribe_token' => $unsubscribeToken,
  204. 'unsubscribe_time' => 0,
  205. 'createtime' => $existing ? (int)$existing->createtime : $now,
  206. 'updatetime' => $now,
  207. ]);
  208. $mailContext = $this->buildMailContext([
  209. 'email' => $email,
  210. 'lang' => $lang,
  211. 'source_page' => $sourcePage,
  212. 'source_key' => $sourceKey !== '' ? $sourceKey : 'footer',
  213. 'remark' => $remark,
  214. 'ip' => $ip,
  215. 'user_agent' => $userAgent,
  216. 'unsubscribe_url' => $this->buildUnsubscribeUrl($subscribe),
  217. ]);
  218. $mail = $this->buildMailByType('subscribe_auto', $lang, $mailContext);
  219. $autoSendWelcome = (int) config('site.email_subscribe_send_welcome', 1) === 1;
  220. $log = new EmailSubscribeLogModel();
  221. $log->save([
  222. 'subscribe_id' => $subscribe->id,
  223. 'email' => $email,
  224. 'mail_type' => 'subscribe_auto',
  225. 'subject' => $mail['subject'],
  226. 'content' => $mail['content'],
  227. 'status' => 0,
  228. 'response_code' => '',
  229. 'response_msg' => $autoSendWelcome ? '待自动发送' : '后台开关已关闭,跳过发送',
  230. 'retry_count' => 0,
  231. 'send_time' => 0,
  232. 'createtime' => $now,
  233. 'updatetime' => $now,
  234. ]);
  235. if ($autoSendWelcome) {
  236. [$sendResult, $responseCode, $responseMsg] = $this->sendSubscribeEmail($email, $mail['subject'], $mail['content']);
  237. $log->status = $sendResult ? 1 : 2;
  238. $log->response_code = $responseCode;
  239. $log->response_msg = $responseMsg ?: ($sendResult ? '订阅自动邮件发送成功' : '订阅自动邮件发送失败');
  240. $log->send_time = $sendResult ? $now : 0;
  241. $subscribe->last_send_status = $sendResult ? 1 : 2;
  242. $subscribe->last_send_time = $sendResult ? $now : 0;
  243. } else {
  244. $sendResult = true;
  245. $responseCode = 'DISABLED';
  246. $responseMsg = '订阅欢迎邮件发送开关已关闭';
  247. $log->status = 0;
  248. $log->response_code = $responseCode;
  249. $log->response_msg = $responseMsg;
  250. $log->send_time = 0;
  251. $subscribe->last_send_status = 0;
  252. $subscribe->last_send_time = 0;
  253. }
  254. $log->updatetime = $now;
  255. $log->save();
  256. $subscribe->updatetime = $now;
  257. $subscribe->save();
  258. Db::commit();
  259. } catch (\Throwable $e) {
  260. Db::rollback();
  261. $this->error('邮件订阅提交失败:' . $e->getMessage());
  262. }
  263. $msg = $sendResult ? '订阅成功,请留意邮箱通知' : '订阅成功,但确认邮件暂未发送成功';
  264. if (!in_array($lang, ['cn', 'zh', 'zh-cn'], true)) {
  265. $msg = $sendResult
  266. ? 'Subscription successful. Please check your email inbox.'
  267. : 'Subscription saved, but the confirmation email was not sent successfully.';
  268. }
  269. $this->success($msg, null, [
  270. 'subscribe_id' => $subscribe->id,
  271. 'last_send_status' => $subscribe->last_send_status,
  272. 'lang' => $lang,
  273. ]);
  274. }
  275. public function unsubscribe()
  276. {
  277. $token = trim((string)$this->request->param('token/s', ''));
  278. if ($token === '') {
  279. $this->error('缺少退订令牌');
  280. }
  281. $row = EmailSubscribeModel::where('unsubscribe_token', $token)->find();
  282. if (!$row) {
  283. $this->error('退订链接无效或已失效');
  284. }
  285. $now = time();
  286. if ((int)$row->status !== 9) {
  287. $row->status = 9;
  288. $row->unsubscribe_time = $now;
  289. $row->updatetime = $now;
  290. $row->save();
  291. EmailSubscribeLogModel::create([
  292. 'subscribe_id' => (int)$row->id,
  293. 'email' => (string)$row->email,
  294. 'mail_type' => 'unsubscribe',
  295. 'subject' => '',
  296. 'content' => '用户通过退订链接取消订阅',
  297. 'status' => 1,
  298. 'response_code' => 'UNSUBSCRIBED',
  299. 'response_msg' => '用户主动退订',
  300. 'retry_count' => 0,
  301. 'send_time' => $now,
  302. 'createtime' => $now,
  303. 'updatetime' => $now,
  304. ]);
  305. }
  306. $lang = $this->normalizeLang((string)$row->lang);
  307. $title = in_array($lang, ['cn', 'zh', 'zh-cn'], true) ? '您已成功退订' : 'You have unsubscribed successfully';
  308. $message = in_array($lang, ['cn', 'zh', 'zh-cn'], true)
  309. ? '您的邮箱已停止接收后续订阅邮件。如需重新订阅,可再次在网站底部提交邮箱。'
  310. : 'Your email has been unsubscribed successfully. You can subscribe again anytime from the website footer.';
  311. $this->assign('pageTitle', $title);
  312. $this->assign('message', $message);
  313. $this->assign('email', (string)$row->email);
  314. return $this->fetch();
  315. }
  316. }