File size: 10,629 Bytes
37cb069
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470bcea
 
 
 
 
 
 
 
 
 
37cb069
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
"""SMTP delivery for ICSAC author correspondence.

Sends HTML (multipart/alternative) email through Gmail SMTP with the ICSAC
logo inlined as a CID attachment. From header uses info@icsacinstitute.org as a Send-As alias
over a backing SMTP mailbox.

Defaults to dry-run mode for safety. Pass send=True to actually deliver.
"""

import os
import re
import smtplib
import ssl
import time
import imaplib
from email.message import EmailMessage

import markdown

import config


LOGO_CID = "icsac-logo"
LOGO_PATH = os.path.join(config.BASE_DIR, "assets", "icsac-logo.png")

HTML_WRAPPER = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #222; max-width: 640px; margin: 0 auto; padding: 24px; background: #fff; }}
h1, h2, h3 {{ color: #111; margin-top: 1.6em; margin-bottom: 0.6em; font-weight: 600; }}
h2 {{ font-size: 1.15em; }}
p {{ margin: 0.8em 0; }}
a {{ color: #2a6cd4; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
blockquote {{ border-left: 3px solid #c8c8c8; margin: 1em 0; padding: 0.3em 1em; color: #555; background: #f7f7f7; font-style: italic; }}
ul {{ padding-left: 1.4em; }}
li {{ margin: 0.3em 0; }}
hr {{ border: none; border-top: 1px solid #e4e4e4; margin: 2em 0 1em; }}
.logo {{ text-align: center; margin-bottom: 28px; }}
.logo img {{ max-width: 320px; height: auto; }}
</style>
</head>
<body>
<div class="logo"><img src="cid:{cid}" alt="ICSAC"></div>
{body}
</body>
</html>
"""


def _markdown_to_plaintext(md: str) -> str:
    """Strip markdown syntax for the plain-text alternative."""
    md = re.sub(r'^#{1,6}\s+', '', md, flags=re.MULTILINE)
    md = re.sub(r'\*\*([^*]+)\*\*', r'\1', md)
    md = re.sub(r'(?<!\*)\*([^*]+)\*(?!\*)', r'\1', md)
    md = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1 (\2)', md)
    md = re.sub(r'^>\s?', '', md, flags=re.MULTILINE)
    md = re.sub(r'\n{3,}', '\n\n', md)
    return md.strip() + "\n"


def _markdown_to_html(md: str) -> str:
    """Render markdown body to HTML with a branded wrapper and inline logo CID."""
    inner = markdown.markdown(md, extensions=["extra", "sane_lists"])
    return HTML_WRAPPER.format(cid=LOGO_CID, body=inner)


def extract_subject(rendered_template: str) -> str:
    """Pull the Subject line out of a rendered email template."""
    for line in rendered_template.splitlines():
        if line.lower().startswith("subject:"):
            return line.split(":", 1)[1].strip()
    return "ICSAC Community"


def extract_body(rendered_template: str) -> str:
    """Strip the template header (title, subject, ---) before the first '---' separator."""
    parts = rendered_template.split("\n---\n", 1)
    if len(parts) == 2:
        return parts[1].strip()
    return rendered_template.strip()


def send_email(to_addr: str, subject: str, body_md: str,
               from_name: str = "ICSAC",
               send: bool = False,
               draft: bool = False,
               attachments: list[tuple[str, bytes]] | None = None,
               outbox_dir: str | None = None,
               eml_filename: str | None = None,
               ) -> tuple[bool, str]:
    """Send a multipart email (plain-text + HTML, inline logo, optional attachments).

    `body_md` is the markdown body (what lives below the template's --- separator).
    `attachments` is an optional list of (filename, raw_bytes) pairs; PDFs go out
    as application/pdf, anything else as application/octet-stream. EmailMessage
    promotes the multipart structure to multipart/mixed automatically when
    attachments are appended on top of the existing alternative+related layout.

    Four delivery modes (mutually exclusive):
      send=False, draft=False  β†’  DRY RUN (default; safe)
      send=True                β†’  SMTP send via Gmail
      draft=True               β†’  IMAP APPEND to Gmail Drafts (operator
                                  manually reviews and sends from Gmail UI)
      outbox_dir=<path>        β†’  Write rendered MIME to <outbox_dir>/<name>.eml.
                                  No SMTP, no IMAP. Used by Tier 2 test-pipeline
                                  runs so a real panel exercise produces a real
                                  on-disk decision artifact without touching
                                  Gmail or the author. `eml_filename` overrides
                                  the on-disk filename; default derives from
                                  the subject slug.
    """
    modes = sum(int(bool(x)) for x in (send, draft, outbox_dir))
    if modes > 1:
        return (False, "send, draft, and outbox_dir are mutually exclusive")
    smtp_host = getattr(config, "SMTP_HOST", "smtp.gmail.com")
    smtp_port = int(getattr(config, "SMTP_PORT", 465))
    smtp_user = getattr(config, "SMTP_USER", "")
    smtp_pass = getattr(config, "SMTP_PASSWORD", "")
    from_addr = getattr(config, "FROM_EMAIL", "info@icsacinstitute.org")
    reply_to = getattr(config, "REPLY_TO_EMAIL", from_addr)

    if not (smtp_user and smtp_pass) and not outbox_dir:
        # outbox_dir mode is purely on-disk; no Gmail creds required.
        # All other modes (DRY RUN, send, draft) need the SMTP creds
        # configured because the From/Reply-To headers are derived from
        # them and IMAP login uses the same pair.
        return (False, "SMTP_USER or SMTP_PASSWORD not configured")
    if not to_addr or "@" not in to_addr:
        return (False, f"invalid recipient: {to_addr!r}")

    plain = _markdown_to_plaintext(body_md)
    html = _markdown_to_html(body_md)

    msg = EmailMessage()
    msg["From"] = f"{from_name} <{from_addr}>"
    msg["To"] = to_addr
    msg["Subject"] = subject
    msg["Reply-To"] = reply_to
    msg.set_content(plain)
    msg.add_alternative(html, subtype="html")

    try:
        with open(LOGO_PATH, "rb") as f:
            logo_data = f.read()
        msg.get_payload()[1].add_related(
            logo_data, maintype="image", subtype="png", cid=f"<{LOGO_CID}>"
        )
    except FileNotFoundError:
        return (False, f"logo asset missing: {LOGO_PATH}")

    for filename, data in (attachments or []):
        subtype = "pdf" if filename.lower().endswith(".pdf") else "octet-stream"
        msg.add_attachment(
            data, maintype="application", subtype=subtype, filename=filename,
        )

    if outbox_dir:
        # Tier-2 test-pipeline target: write the rendered MIME message
        # to disk and return. No SMTP, no IMAP, no Gmail interaction at
        # all. The .eml file is the operator's audit trail that the
        # Tier-2 panel ran to completion and the decision email
        # rendered cleanly without burning a real send to the author.
        try:
            outdir = os.path.abspath(os.path.expanduser(str(outbox_dir)))
            os.makedirs(outdir, exist_ok=True)
            if eml_filename:
                fname = eml_filename
            else:
                slug = re.sub(r"[^A-Za-z0-9]+", "-", subject).strip("-")[:60].lower() or "message"
                fname = f"{slug}.eml"
            if not fname.lower().endswith(".eml"):
                fname += ".eml"
            target = os.path.join(outdir, fname)
            with open(target, "wb") as f:
                f.write(msg.as_bytes())
            return (True, f"wrote outbox eml: {target}")
        except Exception as e:
            return (False, f"outbox write failed: {type(e).__name__}: {e}")

    if draft:
        # IMAP APPEND to Gmail Drafts. Operator opens Gmail, reviews, sends.
        # Same MIME message that SMTP would deliver β€” when the operator opens the
        # draft, Gmail's From: dropdown still lets him pick the alias.
        imap_host = getattr(config, "IMAP_HOST", "imap.gmail.com")
        imap_port = int(getattr(config, "IMAP_PORT", 993))
        imap_user = getattr(config, "IMAP_USER", smtp_user)
        imap_pass = getattr(config, "IMAP_PASSWORD", smtp_pass)
        drafts_folder = getattr(config, "IMAP_DRAFTS_FOLDER", "[Gmail]/Drafts")
        try:
            with imaplib.IMAP4_SSL(imap_host, imap_port) as imap:
                imap.login(imap_user, imap_pass)
                raw = msg.as_bytes()
                date = imaplib.Time2Internaldate(time.time())
                typ, data = imap.append(drafts_folder, "\\Draft", date, raw)
                if typ != "OK":
                    return (False, f"IMAP APPEND returned {typ}: {data!r}")
            return (True, f"draft saved to {drafts_folder} for {to_addr}")
        except imaplib.IMAP4.error as e:
            return (False, f"IMAP error (check Gmail app password + IMAP enabled): {e}")
        except Exception as e:
            return (False, f"draft save failed: {type(e).__name__}: {e}")

    if not send:
        return (True, f"DRY RUN: would send to {to_addr!r} via {smtp_host}:{smtp_port} as {smtp_user} "
                      f"(From: {from_name} <{from_addr}>, subject: {subject!r})")

    try:
        ctx = ssl.create_default_context()
        with smtplib.SMTP_SSL(smtp_host, smtp_port, context=ctx, timeout=30) as server:
            server.login(smtp_user, smtp_pass)
            server.send_message(msg)
        return (True, f"sent to {to_addr}")
    except smtplib.SMTPAuthenticationError as e:
        return (False, f"SMTP auth failed (check Gmail app password): {e}")
    except Exception as e:
        return (False, f"SMTP error: {type(e).__name__}: {e}")


def send_accept_email(to_addr: str, rendered_template: str, send: bool = False) -> tuple[bool, str]:
    return send_email(
        to_addr=to_addr,
        subject=extract_subject(rendered_template),
        body_md=extract_body(rendered_template),
        send=send,
    )


def send_revise_and_resubmit_email(to_addr: str, rendered_template: str, send: bool = False) -> tuple[bool, str]:
    return send_email(
        to_addr=to_addr,
        subject=extract_subject(rendered_template),
        body_md=extract_body(rendered_template),
        send=send,
    )


def send_scope_reject_email(to_addr: str, rendered_template: str, send: bool = False) -> tuple[bool, str]:
    return send_email(
        to_addr=to_addr,
        subject=extract_subject(rendered_template),
        body_md=extract_body(rendered_template),
        send=send,
    )


def send_invite_email(to_addr: str, rendered_template: str, send: bool = False) -> tuple[bool, str]:
    return send_email(
        to_addr=to_addr,
        subject=extract_subject(rendered_template),
        body_md=extract_body(rendered_template),
        send=send,
    )