main
py 144 lines 4.53 KB
Raw
1 """
2 SMTP email sender.
3
4 No agent/tool dependencies.
5 """
6
7 import asyncio
8 import smtplib
9 from email.mime.text import MIMEText
10 from email.mime.multipart import MIMEMultipart
11 from email.mime.base import MIMEBase
12 from email import encoders
13 from dataclasses import dataclass
14
15 from helpers.errors import format_error
16 from helpers.print_style import PrintStyle
17
18
19 # ------------------------------------------------------------------
20 # Data models
21 # ------------------------------------------------------------------
22
23 SMTP_TIMEOUT: int = 30
24
25 @dataclass
26 class SmtpConfig:
27 server: str
28 port: int = 587
29 username: str = ""
30 password: str = ""
31 use_tls: bool = True
32
33
34 # ------------------------------------------------------------------
35 # Send reply
36 # ------------------------------------------------------------------
37
38 async def send_reply(
39 config: SmtpConfig,
40 to: str,
41 subject: str,
42 body: str,
43 in_reply_to: str = "",
44 references: str = "",
45 attachments: list[tuple[str, bytes]] | None = None,
46 ) -> str | None:
47 loop = asyncio.get_event_loop()
48
49 def _sync_send():
50 import markdown
51
52 html_body = markdown.markdown(body, extensions=['extra', 'nl2br'])
53 html_content = f"""
54 <html>
55 <head>
56 <style>
57 body {{ font-family: sans-serif; line-height: 1.5; }}
58 blockquote {{ border-left: 4px solid #ccc; margin: 0; padding-left: 1em; color: #666; }}
59 pre {{ background-color: #f6f8fa; padding: 1em; border-radius: 4px; overflow-x: auto; }}
60 code {{ font-family: monospace; background-color: #f6f8fa; padding: 0.2em 0.4em; border-radius: 3px; }}
61 pre code {{ padding: 0; background-color: transparent; }}
62 </style>
63 </head>
64 <body>
65 {html_body}
66 </body>
67 </html>
68 """
69
70 if attachments:
71 msg = MIMEMultipart("mixed")
72
73 alt_part = MIMEMultipart("alternative")
74 alt_part.attach(MIMEText(body, "plain", "utf-8"))
75 alt_part.attach(MIMEText(html_content, "html", "utf-8"))
76 msg.attach(alt_part)
77
78 for filename, content in attachments:
79 part = MIMEBase("application", "octet-stream")
80 part.set_payload(content)
81 encoders.encode_base64(part)
82 part.add_header(
83 "Content-Disposition",
84 f"attachment; filename={filename}",
85 )
86 msg.attach(part)
87 else:
88 msg = MIMEMultipart("alternative")
89 msg.attach(MIMEText(body, "plain", "utf-8"))
90 msg.attach(MIMEText(html_content, "html", "utf-8"))
91
92 msg["From"] = config.username
93 msg["To"] = to
94 msg["Subject"] = subject
95
96 if in_reply_to:
97 msg["In-Reply-To"] = in_reply_to
98 msg["References"] = references or in_reply_to
99
100 if config.use_tls:
101 with smtplib.SMTP(config.server, config.port, timeout=SMTP_TIMEOUT) as server:
102 server.ehlo()
103 server.starttls()
104 server.ehlo()
105 server.login(config.username, config.password)
106 server.send_message(msg)
107 else:
108 with smtplib.SMTP_SSL(config.server, config.port, timeout=SMTP_TIMEOUT) as server:
109 server.login(config.username, config.password)
110 server.send_message(msg)
111
112 try:
113 await loop.run_in_executor(None, _sync_send)
114 PrintStyle.success(f"Email sent to {to}: {subject}")
115 return None
116 except Exception as e:
117 error = format_error(e)
118 PrintStyle.error(f"Email send failed: {error}")
119 return error
120
121
122 # ------------------------------------------------------------------
123 # Connection test (auth only, no email sent)
124 # ------------------------------------------------------------------
125
126 async def test_smtp(config: SmtpConfig) -> str | None:
127 loop = asyncio.get_event_loop()
128
129 def _sync_test():
130 if config.use_tls:
131 with smtplib.SMTP(config.server, config.port, timeout=SMTP_TIMEOUT) as server:
132 server.ehlo()
133 server.starttls()
134 server.ehlo()
135 server.login(config.username, config.password)
136 else:
137 with smtplib.SMTP_SSL(config.server, config.port, timeout=SMTP_TIMEOUT) as server:
138 server.login(config.username, config.password)
139
140 try:
141 await loop.run_in_executor(None, _sync_test)
142 return None
143 except Exception as e:
144 return format_error(e)