Files
tyapi-server/internal/infrastructure/external/email/qq_email_service.go

713 lines
19 KiB
Go
Raw Normal View History

2025-08-02 02:54:21 +08:00
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
}