interloper-api 0.49.0__tar.gz → 0.50.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {interloper_api-0.49.0 → interloper_api-0.50.0}/PKG-INFO +1 -1
- {interloper_api-0.49.0 → interloper_api-0.50.0}/pyproject.toml +1 -1
- {interloper_api-0.49.0 → interloper_api-0.50.0}/pyproject.toml.orig +1 -1
- interloper_api-0.50.0/src/interloper_api/email.py +153 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/components.py +10 -2
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/organisations.py +1 -0
- interloper_api-0.49.0/src/interloper_api/email.py +0 -79
- {interloper_api-0.49.0 → interloper_api-0.50.0}/README.md +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/app.py +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/components.py +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/dependencies.py +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/admin.py +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/auth.py +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/backfills.py +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/oauth.py +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/runs.py +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/tokens.py +0 -0
- {interloper_api-0.49.0 → interloper_api-0.50.0}/src/interloper_api/routes/ws.py +0 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""SMTP email utilities for sending invitation emails."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import logging
|
|
7
|
+
import smtplib
|
|
8
|
+
from email.mime.multipart import MIMEMultipart
|
|
9
|
+
from email.mime.text import MIMEText
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from interloper_db.store.auth import INVITATION_EXPIRY_DAYS
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Email clients ignore <style> blocks and external assets, so everything is
|
|
17
|
+
# table-based with inline styles. Colors are the design tokens: navy #0B2A42
|
|
18
|
+
# (header), accent #2D7DF6 (button/links), grays from the app palette.
|
|
19
|
+
_FONT_STACK = "-apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_invite_text(org_name: str, inviter_name: str, invite_url: str) -> str:
|
|
23
|
+
"""Render the plain-text alternative of the invitation email."""
|
|
24
|
+
return (
|
|
25
|
+
f"{inviter_name} has invited you to join the {org_name} organisation on Interloper.\n\n"
|
|
26
|
+
f"Accept the invitation:\n{invite_url}\n\n"
|
|
27
|
+
f"This invitation expires in {INVITATION_EXPIRY_DAYS} days. If you weren't expecting it,\n"
|
|
28
|
+
"you can safely ignore this email."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_invite_html(org_name: str, inviter_name: str, invite_url: str, logo_url: str | None = None) -> str:
|
|
33
|
+
"""Render the HTML body of the invitation email.
|
|
34
|
+
|
|
35
|
+
The logo is a hosted PNG (``logo_url``), not an inline SVG: Gmail and
|
|
36
|
+
Outlook strip ``<svg>`` and block ``data:`` URIs. The text wordmark stays
|
|
37
|
+
next to it so the header still reads when images are blocked.
|
|
38
|
+
"""
|
|
39
|
+
org = html.escape(org_name)
|
|
40
|
+
inviter = html.escape(inviter_name)
|
|
41
|
+
url = html.escape(invite_url, quote=True)
|
|
42
|
+
logo = (
|
|
43
|
+
f'<img src="{html.escape(logo_url, quote=True)}" width="26" height="26" alt=""'
|
|
44
|
+
' style="vertical-align: middle; margin-right: 10px;">'
|
|
45
|
+
if logo_url
|
|
46
|
+
else ""
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return f"""\
|
|
50
|
+
<!DOCTYPE html>
|
|
51
|
+
<html>
|
|
52
|
+
<body style="margin: 0; padding: 0; background: #fbfbfc;">
|
|
53
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #fbfbfc;">
|
|
54
|
+
<tr>
|
|
55
|
+
<td align="center" style="padding: 40px 16px;">
|
|
56
|
+
<table role="presentation" width="560" cellpadding="0" cellspacing="0"
|
|
57
|
+
style="max-width: 560px; width: 100%; background: #ffffff; border: 1px solid #e8e8ec;
|
|
58
|
+
border-radius: 12px; overflow: hidden;">
|
|
59
|
+
<tr>
|
|
60
|
+
<td style="background: #0b2a42; padding: 16px 32px;">
|
|
61
|
+
{logo}<span style="font-family: {_FONT_STACK}; font-size: 17px; font-weight: 600;
|
|
62
|
+
letter-spacing: -0.01em; color: #ffffff; vertical-align: middle;">
|
|
63
|
+
Interloper
|
|
64
|
+
</span>
|
|
65
|
+
</td>
|
|
66
|
+
</tr>
|
|
67
|
+
<tr>
|
|
68
|
+
<td style="padding: 32px;">
|
|
69
|
+
<h1 style="margin: 0 0 12px; font-family: {_FONT_STACK}; font-size: 20px;
|
|
70
|
+
font-weight: 700; letter-spacing: -0.01em; color: #1d1d1f;">
|
|
71
|
+
Join {org} on Interloper
|
|
72
|
+
</h1>
|
|
73
|
+
<p style="margin: 0 0 24px; font-family: {_FONT_STACK}; font-size: 14px;
|
|
74
|
+
line-height: 1.6; color: #6b6b70;">
|
|
75
|
+
<strong style="color: #1d1d1f;">{inviter}</strong> has invited you to join the
|
|
76
|
+
<strong style="color: #1d1d1f;">{org}</strong> organisation on Interloper.
|
|
77
|
+
</p>
|
|
78
|
+
<a href="{url}"
|
|
79
|
+
style="display: inline-block; padding: 11px 22px; background: #2d7df6;
|
|
80
|
+
font-family: {_FONT_STACK}; font-size: 14px; font-weight: 600;
|
|
81
|
+
color: #ffffff; text-decoration: none; border-radius: 10px;">
|
|
82
|
+
Accept invitation
|
|
83
|
+
</a>
|
|
84
|
+
<p style="margin: 24px 0 0; font-family: {_FONT_STACK}; font-size: 12.5px;
|
|
85
|
+
line-height: 1.6; color: #9a9aa0;">
|
|
86
|
+
Or copy and paste this link into your browser:<br>
|
|
87
|
+
<a href="{url}" style="color: #2d7df6; word-break: break-all;">{url}</a>
|
|
88
|
+
</p>
|
|
89
|
+
</td>
|
|
90
|
+
</tr>
|
|
91
|
+
<tr>
|
|
92
|
+
<td style="padding: 16px 32px; border-top: 1px solid #f0f0f3;">
|
|
93
|
+
<p style="margin: 0; font-family: {_FONT_STACK}; font-size: 12.5px;
|
|
94
|
+
line-height: 1.6; color: #9a9aa0;">
|
|
95
|
+
This invitation expires in {INVITATION_EXPIRY_DAYS} days.
|
|
96
|
+
If you weren't expecting it, you can safely ignore this email.
|
|
97
|
+
</p>
|
|
98
|
+
</td>
|
|
99
|
+
</tr>
|
|
100
|
+
</table>
|
|
101
|
+
</td>
|
|
102
|
+
</tr>
|
|
103
|
+
</table>
|
|
104
|
+
</body>
|
|
105
|
+
</html>"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def send_invite_email(
|
|
109
|
+
smtp_config: Any,
|
|
110
|
+
to: str,
|
|
111
|
+
org_name: str,
|
|
112
|
+
inviter_name: str,
|
|
113
|
+
invite_url: str,
|
|
114
|
+
logo_url: str | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Send an organisation invitation email via SMTP.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
smtp_config: SmtpConfig instance with host, port, user, password, from_addr.
|
|
120
|
+
to: Recipient email address.
|
|
121
|
+
org_name: Name of the organisation.
|
|
122
|
+
inviter_name: Display name of the person who sent the invite.
|
|
123
|
+
invite_url: Full URL to accept the invitation.
|
|
124
|
+
logo_url: Absolute URL of the header logo image, if available.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
RuntimeError: If SMTP is not configured.
|
|
128
|
+
smtplib.SMTPException: If sending fails.
|
|
129
|
+
"""
|
|
130
|
+
if not smtp_config.enabled:
|
|
131
|
+
raise RuntimeError("SMTP is not configured. Set smtp.host, smtp.user, and smtp.password.")
|
|
132
|
+
|
|
133
|
+
msg = MIMEMultipart("alternative")
|
|
134
|
+
msg["Subject"] = f"You've been invited to join {org_name} on Interloper"
|
|
135
|
+
msg["From"] = smtp_config.from_addr
|
|
136
|
+
msg["To"] = to
|
|
137
|
+
|
|
138
|
+
msg.attach(MIMEText(render_invite_text(org_name, inviter_name, invite_url), "plain"))
|
|
139
|
+
msg.attach(MIMEText(render_invite_html(org_name, inviter_name, invite_url, logo_url), "html"))
|
|
140
|
+
|
|
141
|
+
logger.info("Sending invite email to %s", to)
|
|
142
|
+
|
|
143
|
+
if smtp_config.port == 465:
|
|
144
|
+
with smtplib.SMTP_SSL(smtp_config.host, smtp_config.port) as server:
|
|
145
|
+
server.login(smtp_config.user, smtp_config.password)
|
|
146
|
+
server.sendmail(smtp_config.from_addr, to, msg.as_string())
|
|
147
|
+
else:
|
|
148
|
+
with smtplib.SMTP(smtp_config.host, smtp_config.port) as server:
|
|
149
|
+
server.starttls()
|
|
150
|
+
server.login(smtp_config.user, smtp_config.password)
|
|
151
|
+
server.sendmail(smtp_config.from_addr, to, msg.as_string())
|
|
152
|
+
|
|
153
|
+
logger.info("Invite email sent to %s", to)
|
|
@@ -299,6 +299,8 @@ def update_component(
|
|
|
299
299
|
raise HTTPException(status_code=400, detail=str(e))
|
|
300
300
|
except NotFoundError as e:
|
|
301
301
|
raise HTTPException(status_code=404, detail=str(e))
|
|
302
|
+
except InUseError as e:
|
|
303
|
+
raise HTTPException(status_code=409, detail={"message": str(e), "used_by": e.referrers})
|
|
302
304
|
return _to_response(row, store, include_config=True)
|
|
303
305
|
|
|
304
306
|
|
|
@@ -353,9 +355,15 @@ def remove_relation(
|
|
|
353
355
|
user: Profile = Depends(get_current_user),
|
|
354
356
|
store: Store = Depends(get_store),
|
|
355
357
|
) -> None:
|
|
356
|
-
"""Remove a component's relations of one type toward one destination.
|
|
358
|
+
"""Remove a component's relations of one type toward one destination.
|
|
359
|
+
|
|
360
|
+
Refused (400) for required dependency slots — repoint them instead.
|
|
361
|
+
"""
|
|
357
362
|
load_authorized(store.get_component, component_id, user, store, label="Component", minimum="editor")
|
|
358
|
-
|
|
363
|
+
try:
|
|
364
|
+
store.remove_relation(component_id, type=type, dst_id=dst_id)
|
|
365
|
+
except ConfigError as e:
|
|
366
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
359
367
|
|
|
360
368
|
|
|
361
369
|
# -- Partition endpoint ---------------------------------------------------------
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
"""SMTP email utilities for sending invitation emails."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
import smtplib
|
|
7
|
-
from email.mime.multipart import MIMEMultipart
|
|
8
|
-
from email.mime.text import MIMEText
|
|
9
|
-
from typing import Any
|
|
10
|
-
|
|
11
|
-
logger = logging.getLogger(__name__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def send_invite_email(
|
|
15
|
-
smtp_config: Any,
|
|
16
|
-
to: str,
|
|
17
|
-
org_name: str,
|
|
18
|
-
inviter_name: str,
|
|
19
|
-
invite_url: str,
|
|
20
|
-
) -> None:
|
|
21
|
-
"""Send an organisation invitation email via SMTP.
|
|
22
|
-
|
|
23
|
-
Args:
|
|
24
|
-
smtp_config: SmtpConfig instance with host, port, user, password, from_addr.
|
|
25
|
-
to: Recipient email address.
|
|
26
|
-
org_name: Name of the organisation.
|
|
27
|
-
inviter_name: Display name of the person who sent the invite.
|
|
28
|
-
invite_url: Full URL to accept the invitation.
|
|
29
|
-
|
|
30
|
-
Raises:
|
|
31
|
-
RuntimeError: If SMTP is not configured.
|
|
32
|
-
smtplib.SMTPException: If sending fails.
|
|
33
|
-
"""
|
|
34
|
-
if not smtp_config.enabled:
|
|
35
|
-
raise RuntimeError("SMTP is not configured. Set smtp.host, smtp.user, and smtp.password.")
|
|
36
|
-
|
|
37
|
-
msg = MIMEMultipart("alternative")
|
|
38
|
-
msg["Subject"] = f"You've been invited to join {org_name} on Interloper"
|
|
39
|
-
msg["From"] = smtp_config.from_addr
|
|
40
|
-
msg["To"] = to
|
|
41
|
-
|
|
42
|
-
text = (
|
|
43
|
-
f"{inviter_name} has invited you to join the {org_name} organisation on Interloper.\n\n"
|
|
44
|
-
f"Click the link below to accept the invitation:\n{invite_url}\n\n"
|
|
45
|
-
"This invitation expires in 7 days."
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
html = f"""\
|
|
49
|
-
<html>
|
|
50
|
-
<body style="font-family: sans-serif; color: #333;">
|
|
51
|
-
<h2>You've been invited to join {org_name}</h2>
|
|
52
|
-
<p>{inviter_name} has invited you to join the <strong>{org_name}</strong> organisation on Interloper.</p>
|
|
53
|
-
<p>
|
|
54
|
-
<a href="{invite_url}"
|
|
55
|
-
style="display: inline-block; padding: 10px 20px; background: #2563eb;
|
|
56
|
-
color: #fff; text-decoration: none; border-radius: 6px;">
|
|
57
|
-
Accept Invitation
|
|
58
|
-
</a>
|
|
59
|
-
</p>
|
|
60
|
-
<p style="color: #666; font-size: 0.875rem;">This invitation expires in 7 days.</p>
|
|
61
|
-
</body>
|
|
62
|
-
</html>"""
|
|
63
|
-
|
|
64
|
-
msg.attach(MIMEText(text, "plain"))
|
|
65
|
-
msg.attach(MIMEText(html, "html"))
|
|
66
|
-
|
|
67
|
-
logger.info("Sending invite email to %s", to)
|
|
68
|
-
|
|
69
|
-
if smtp_config.port == 465:
|
|
70
|
-
with smtplib.SMTP_SSL(smtp_config.host, smtp_config.port) as server:
|
|
71
|
-
server.login(smtp_config.user, smtp_config.password)
|
|
72
|
-
server.sendmail(smtp_config.from_addr, to, msg.as_string())
|
|
73
|
-
else:
|
|
74
|
-
with smtplib.SMTP(smtp_config.host, smtp_config.port) as server:
|
|
75
|
-
server.starttls()
|
|
76
|
-
server.login(smtp_config.user, smtp_config.password)
|
|
77
|
-
server.sendmail(smtp_config.from_addr, to, msg.as_string())
|
|
78
|
-
|
|
79
|
-
logger.info("Invite email sent to %s", to)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|