Files
tyapi-server/internal/infrastructure/external/email/qq_email_service.go
2025-08-02 02:54:21 +08:00

713 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package email
import (
"context"
"crypto/tls"
"fmt"
"html/template"
"net"
"net/smtp"
"strings"
"time"
"go.uber.org/zap"
"tyapi-server/internal/config"
)
// QQEmailService QQ邮箱服务
type QQEmailService struct {
config config.EmailConfig
logger *zap.Logger
}
// EmailData 邮件数据
type EmailData struct {
To string `json:"to"`
Subject string `json:"subject"`
Content string `json:"content"`
Data map[string]interface{} `json:"data"`
}
// InvoiceEmailData 发票邮件数据
type InvoiceEmailData struct {
CompanyName string `json:"company_name"`
Amount string `json:"amount"`
InvoiceType string `json:"invoice_type"`
FileURL string `json:"file_url"`
FileName string `json:"file_name"`
ReceivingEmail string `json:"receiving_email"`
ApprovedAt string `json:"approved_at"`
}
// NewQQEmailService 创建QQ邮箱服务
func NewQQEmailService(config config.EmailConfig, logger *zap.Logger) *QQEmailService {
return &QQEmailService{
config: config,
logger: logger,
}
}
// SendEmail 发送邮件
func (s *QQEmailService) SendEmail(ctx context.Context, data *EmailData) error {
s.logger.Info("开始发送邮件",
zap.String("to", data.To),
zap.String("subject", data.Subject),
)
// 构建邮件内容
message := s.buildEmailMessage(data)
// 发送邮件
err := s.sendSMTP(data.To, data.Subject, message)
if err != nil {
s.logger.Error("发送邮件失败",
zap.String("to", data.To),
zap.String("subject", data.Subject),
zap.Error(err),
)
return fmt.Errorf("发送邮件失败: %w", err)
}
s.logger.Info("邮件发送成功",
zap.String("to", data.To),
zap.String("subject", data.Subject),
)
return nil
}
// SendInvoiceEmail 发送发票邮件
func (s *QQEmailService) SendInvoiceEmail(ctx context.Context, data *InvoiceEmailData) error {
s.logger.Info("开始发送发票邮件",
zap.String("to", data.ReceivingEmail),
zap.String("company_name", data.CompanyName),
zap.String("amount", data.Amount),
)
// 构建邮件内容
subject := "您的发票已开具成功"
content := s.buildInvoiceEmailContent(data)
emailData := &EmailData{
To: data.ReceivingEmail,
Subject: subject,
Content: content,
Data: map[string]interface{}{
"company_name": data.CompanyName,
"amount": data.Amount,
"invoice_type": data.InvoiceType,
"file_url": data.FileURL,
"file_name": data.FileName,
"approved_at": data.ApprovedAt,
},
}
return s.SendEmail(ctx, emailData)
}
// buildEmailMessage 构建邮件消息
func (s *QQEmailService) buildEmailMessage(data *EmailData) string {
headers := make(map[string]string)
headers["From"] = s.config.FromEmail
headers["To"] = data.To
headers["Subject"] = data.Subject
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/html; charset=UTF-8"
var message strings.Builder
for key, value := range headers {
message.WriteString(fmt.Sprintf("%s: %s\r\n", key, value))
}
message.WriteString("\r\n")
message.WriteString(data.Content)
return message.String()
}
// buildInvoiceEmailContent 构建发票邮件内容
func (s *QQEmailService) buildInvoiceEmailContent(data *InvoiceEmailData) string {
htmlTemplate := `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>发票开具成功通知</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', 'Microsoft YaHei', Arial, sans-serif;
line-height: 1.6;
color: #2d3748;
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 650px;
margin: 0 auto;
background: #ffffff;
border-radius: 24px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.08);
overflow: hidden;
border: 1px solid #e2e8f0;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 50px 40px 40px;
text-align: center;
position: relative;
overflow: hidden;
}
.header::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.08) 0%, transparent 70%);
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-20px) rotate(180deg); }
}
.success-icon {
font-size: 48px;
margin-bottom: 16px;
position: relative;
z-index: 1;
opacity: 0.9;
}
.header h1 {
font-size: 24px;
font-weight: 500;
margin: 0;
position: relative;
z-index: 1;
letter-spacing: 0.5px;
}
.content {
padding: 0;
}
.greeting {
padding: 40px 40px 20px;
text-align: center;
background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%);
}
.greeting p {
font-size: 16px;
color: #4a5568;
margin-bottom: 8px;
font-weight: 400;
}
.access-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px;
text-align: center;
position: relative;
overflow: hidden;
margin: 0 20px 30px;
border-radius: 20px;
}
.access-section::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
animation: shimmer 8s ease-in-out infinite;
}
@keyframes shimmer {
0%, 100% { transform: translateX(-100%) translateY(-100%) rotate(0deg); }
50% { transform: translateX(100%) translateY(100%) rotate(180deg); }
}
.access-section h3 {
color: white;
font-size: 22px;
font-weight: 600;
margin-bottom: 12px;
position: relative;
z-index: 1;
}
.access-section p {
color: rgba(255, 255, 255, 0.9);
margin-bottom: 25px;
position: relative;
z-index: 1;
font-size: 15px;
}
.access-btn {
display: inline-block;
background: rgba(255, 255, 255, 0.15);
color: white;
padding: 16px 32px;
text-decoration: none;
border-radius: 50px;
font-weight: 600;
font-size: 15px;
border: 2px solid rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
position: relative;
z-index: 1;
backdrop-filter: blur(10px);
letter-spacing: 0.3px;
}
.access-btn:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-3px);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.info-section {
padding: 0 40px 40px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin: 30px 0;
}
.info-item {
background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%);
padding: 24px;
border-radius: 16px;
border: 1px solid #e2e8f0;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
.info-item:hover {
transform: translateY(-2px);
box-shadow: 0 12px 25px -5px rgba(102, 126, 234, 0.1);
border-color: #cbd5e0;
}
.info-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 0 2px 2px 0;
}
.info-label {
font-weight: 600;
color: #718096;
display: block;
margin-bottom: 8px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.8px;
}
.info-value {
color: #2d3748;
font-size: 16px;
font-weight: 500;
position: relative;
z-index: 1;
}
.notes-section {
background: linear-gradient(135deg, #f0fff4 0%, #ffffff 100%);
padding: 30px;
border-radius: 16px;
margin: 30px 0;
border: 1px solid #c6f6d5;
position: relative;
}
.notes-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
border-radius: 0 2px 2px 0;
}
.notes-section h4 {
color: #2f855a;
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
display: flex;
align-items: center;
}
.notes-section h4::before {
content: '📋';
margin-right: 8px;
font-size: 18px;
}
.notes-section ul {
list-style: none;
padding: 0;
}
.notes-section li {
color: #4a5568;
margin-bottom: 10px;
padding-left: 24px;
position: relative;
font-size: 14px;
}
.notes-section li::before {
content: '✓';
color: #48bb78;
font-weight: bold;
position: absolute;
left: 0;
font-size: 16px;
}
.footer {
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
color: rgba(255, 255, 255, 0.8);
padding: 35px 40px;
text-align: center;
font-size: 14px;
}
.footer p {
margin-bottom: 8px;
line-height: 1.5;
}
.footer-divider {
width: 60px;
height: 2px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 20px auto;
border-radius: 1px;
}
@media (max-width: 600px) {
.container {
margin: 10px;
border-radius: 20px;
}
.header {
padding: 40px 30px 30px;
}
.greeting {
padding: 30px 30px 20px;
}
.access-section {
margin: 0 15px 25px;
padding: 30px 25px;
}
.info-section {
padding: 0 30px 30px;
}
.info-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.footer {
padding: 30px 30px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="success-icon">✓</div>
<h1>发票已开具完成</h1>
</div>
<div class="content">
<div class="greeting">
<p>尊敬的用户,您好!</p>
<p>您的发票申请已审核通过,发票已成功开具。</p>
</div>
<div class="access-section">
<h3>📄 发票访问链接</h3>
<p>您的发票已准备就绪,请点击下方按钮访问查看页面</p>
<a href="{{.FileURL}}" class="access-btn" target="_blank">
🔗 访问发票页面
</a>
</div>
<div class="info-section">
<div class="info-grid">
<div class="info-item">
<span class="info-label">公司名称</span>
<span class="info-value">{{.CompanyName}}</span>
</div>
<div class="info-item">
<span class="info-label">发票金额</span>
<span class="info-value">¥{{.Amount}}</span>
</div>
<div class="info-item">
<span class="info-label">发票类型</span>
<span class="info-value">{{.InvoiceType}}</span>
</div>
<div class="info-item">
<span class="info-label">开具时间</span>
<span class="info-value">{{.ApprovedAt}}</span>
</div>
</div>
<div class="notes-section">
<h4>注意事项</h4>
<ul>
<li>访问页面后可在页面内下载发票文件</li>
<li>请妥善保管发票文件,建议打印存档</li>
<li>如有疑问,请回到我们平台进行下载</li>
</ul>
</div>
</div>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿回复</p>
<div class="footer-divider"></div>
<p>天远数据 API 服务平台</p>
<p>发送时间:{{.CurrentTime}}</p>
</div>
</div>
</body>
</html>`
// 解析模板
tmpl, err := template.New("invoice_email").Parse(htmlTemplate)
if err != nil {
s.logger.Error("解析邮件模板失败", zap.Error(err))
return s.buildSimpleInvoiceEmail(data)
}
// 准备模板数据
templateData := struct {
CompanyName string
Amount string
InvoiceType string
FileURL string
FileName string
ApprovedAt string
CurrentTime string
Domain string
}{
CompanyName: data.CompanyName,
Amount: data.Amount,
InvoiceType: data.InvoiceType,
FileURL: data.FileURL,
FileName: data.FileName,
ApprovedAt: data.ApprovedAt,
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
Domain: s.config.Domain,
}
// 执行模板
var content strings.Builder
err = tmpl.Execute(&content, templateData)
if err != nil {
s.logger.Error("执行邮件模板失败", zap.Error(err))
return s.buildSimpleInvoiceEmail(data)
}
return content.String()
}
// buildSimpleInvoiceEmail 构建简单的发票邮件内容(备用方案)
func (s *QQEmailService) buildSimpleInvoiceEmail(data *InvoiceEmailData) string {
return fmt.Sprintf(`
发票开具成功通知
尊敬的用户,您好!
您的发票申请已审核通过,发票已成功开具。
发票信息:
- 公司名称:%s
- 发票金额:¥%s
- 发票类型:%s
- 开具时间:%s
发票文件下载链接:%s
文件名:%s
如有疑问请访问控制台查看详细信息https://%s
天远数据 API 服务平台
%s
`, data.CompanyName, data.Amount, data.InvoiceType, data.ApprovedAt, data.FileURL, data.FileName, s.config.Domain, time.Now().Format("2006-01-02 15:04:05"))
}
// sendSMTP 通过SMTP发送邮件
func (s *QQEmailService) sendSMTP(to, subject, message string) error {
// 构建认证信息
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
// 构建收件人列表
toList := []string{to}
// 发送邮件
if s.config.UseSSL {
// QQ邮箱587端口使用STARTTLS465端口使用直接SSL
if s.config.Port == 587 {
// 使用STARTTLS (587端口)
conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", s.config.Host, s.config.Port))
if err != nil {
return fmt.Errorf("连接SMTP服务器失败: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.config.Host)
if err != nil {
return fmt.Errorf("创建SMTP客户端失败: %w", err)
}
defer client.Close()
// 启用STARTTLS
if err = client.StartTLS(&tls.Config{
ServerName: s.config.Host,
InsecureSkipVerify: false,
}); err != nil {
return fmt.Errorf("启用STARTTLS失败: %w", err)
}
// 认证
if err = client.Auth(auth); err != nil {
return fmt.Errorf("SMTP认证失败: %w", err)
}
// 设置发件人
if err = client.Mail(s.config.FromEmail); err != nil {
return fmt.Errorf("设置发件人失败: %w", err)
}
// 设置收件人
for _, recipient := range toList {
if err = client.Rcpt(recipient); err != nil {
return fmt.Errorf("设置收件人失败: %w", err)
}
}
// 发送邮件内容
writer, err := client.Data()
if err != nil {
return fmt.Errorf("准备发送邮件内容失败: %w", err)
}
defer writer.Close()
_, err = writer.Write([]byte(message))
if err != nil {
return fmt.Errorf("发送邮件内容失败: %w", err)
}
} else {
// 使用直接SSL连接 (465端口)
tlsConfig := &tls.Config{
ServerName: s.config.Host,
InsecureSkipVerify: false,
}
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", s.config.Host, s.config.Port), tlsConfig)
if err != nil {
return fmt.Errorf("连接SMTP服务器失败: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.config.Host)
if err != nil {
return fmt.Errorf("创建SMTP客户端失败: %w", err)
}
defer client.Close()
// 认证
if err = client.Auth(auth); err != nil {
return fmt.Errorf("SMTP认证失败: %w", err)
}
// 设置发件人
if err = client.Mail(s.config.FromEmail); err != nil {
return fmt.Errorf("设置发件人失败: %w", err)
}
// 设置收件人
for _, recipient := range toList {
if err = client.Rcpt(recipient); err != nil {
return fmt.Errorf("设置收件人失败: %w", err)
}
}
// 发送邮件内容
writer, err := client.Data()
if err != nil {
return fmt.Errorf("准备发送邮件内容失败: %w", err)
}
defer writer.Close()
_, err = writer.Write([]byte(message))
if err != nil {
return fmt.Errorf("发送邮件内容失败: %w", err)
}
}
} else {
// 使用普通连接
err := smtp.SendMail(
fmt.Sprintf("%s:%d", s.config.Host, s.config.Port),
auth,
s.config.FromEmail,
toList,
[]byte(message),
)
if err != nil {
return fmt.Errorf("发送邮件失败: %w", err)
}
}
return nil
}