atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

Select the types of activity you want to include in your feed.

Add Comail mail provider

zzstoatzz (Jun 12, 2026, 2:28 PM -0500) d2186ce5 97c30476

+204 -13
+4 -2
README.md
··· 66 66 run behind a public https origin: 67 67 68 68 ```sh 69 - ZDS_RESEND_API_KEY=... \ 70 - ZDS_EMAIL_FROM='ZDS <pds@example.com>' \ 69 + ZDS_MAIL_PROVIDER=comail \ 70 + ZDS_COMAIL_API_KEY=... \ 71 + ZDS_COMAIL_DID='did:plc:mailIdentity' \ 72 + ZDS_EMAIL_FROM='ZDS <noreply@example.com>' \ 71 73 ZDS_HOST=0.0.0.0 \ 72 74 ZDS_PORT=2583 \ 73 75 ZDS_DB=/var/lib/zds/zds.sqlite3 \
+9 -2
docs/operations.md
··· 79 79 - `ZDS_JWT_SECRET`: stable secret for access and refresh JWTs. 80 80 - `ZDS_HANDLE_DOMAINS`: comma-separated domains advertised by 81 81 `describeServer`. 82 - - `ZDS_RESEND_API_KEY` and `ZDS_EMAIL_FROM`: email delivery for account and PLC 83 - tokens. 82 + - `ZDS_MAIL_PROVIDER`: email delivery provider. Default: `comail`. Supported: 83 + `comail`, `resend`. 84 + - `ZDS_EMAIL_FROM`: sender address for account and PLC email tokens. The 85 + domain must be enrolled with the configured provider. 86 + - `ZDS_COMAIL_API_KEY` and `ZDS_COMAIL_DID`: Comail HTTP Send API credentials. 87 + `ZDS_COMAIL_DID` is the atproto DID that enrolled the sending domain; ZDS 88 + sends it as `X-Atmos-DID`. 89 + - `ZDS_RESEND_API_KEY`: Resend API key, used only when 90 + `ZDS_MAIL_PROVIDER=resend`. 84 91 - `ZDS_BLOB_UPLOAD_LIMIT`: upload body limit. Default: `100000000`. 85 92 - `ZDS_BLOBSTORE_PATH`: disk blobstore root. 86 93 - `ZDS_CRAWLERS`: comma-separated relay crawl targets.
+1
fly.toml
··· 14 14 ZDS_CRAWLERS = "https://bsky.network,https://vsky.network" 15 15 ZDS_BLOB_UPLOAD_LIMIT = "100000000" 16 16 ZDS_BLOBSTORE_PATH = "/data/blobs" 17 + ZDS_MAIL_PROVIDER = "comail" 17 18 ZDS_INVITE_REQUIRED = "true" 18 19 ZDS_PERMISSIONED_DATA = "true" 19 20
+38
src/core/config.zig
··· 5 5 var plc_directory_value: []const u8 = "https://plc.directory"; 6 6 var plc_rotation_key_value: ?[]const u8 = null; 7 7 var recovery_did_key_value: ?[]const u8 = null; 8 + var mail_provider_value: MailProvider = .comail; 8 9 var resend_api_key_value: ?[]const u8 = null; 10 + var comail_api_key_value: ?[]const u8 = null; 11 + var comail_did_value: ?[]const u8 = null; 9 12 var email_from_value: ?[]const u8 = null; 10 13 var blob_upload_limit_value: usize = 100_000_000; 11 14 var blobstore_path_value: []const u8 = "dev/blobs"; ··· 26 29 debug = 2, 27 30 }; 28 31 32 + pub const MailProvider = enum { 33 + comail, 34 + resend, 35 + }; 36 + 29 37 pub fn publicUrl() []const u8 { 30 38 return public_url_value; 31 39 } ··· 46 54 return recovery_did_key_value; 47 55 } 48 56 57 + pub fn mailProvider() MailProvider { 58 + return mail_provider_value; 59 + } 60 + 49 61 pub fn resendApiKey() ?[]const u8 { 50 62 return resend_api_key_value; 51 63 } 52 64 65 + pub fn comailApiKey() ?[]const u8 { 66 + return comail_api_key_value; 67 + } 68 + 69 + pub fn comailDid() ?[]const u8 { 70 + return comail_did_value; 71 + } 72 + 53 73 pub fn emailFrom() ?[]const u8 { 54 74 return email_from_value; 55 75 } ··· 126 146 recovery_did_key_value = value; 127 147 } 128 148 149 + pub fn setMailProvider(value: MailProvider) void { 150 + mail_provider_value = value; 151 + } 152 + 129 153 pub fn setResendApiKey(value: ?[]const u8) void { 130 154 resend_api_key_value = value; 131 155 } 132 156 157 + pub fn setComailApiKey(value: ?[]const u8) void { 158 + comail_api_key_value = value; 159 + } 160 + 161 + pub fn setComailDid(value: ?[]const u8) void { 162 + comail_did_value = value; 163 + } 164 + 133 165 pub fn setEmailFrom(value: ?[]const u8) void { 134 166 email_from_value = value; 135 167 } ··· 187 219 if (std.ascii.eqlIgnoreCase(value, "err")) return .err; 188 220 if (std.ascii.eqlIgnoreCase(value, "info")) return .info; 189 221 if (std.ascii.eqlIgnoreCase(value, "debug")) return .debug; 222 + return null; 223 + } 224 + 225 + pub fn parseMailProvider(value: []const u8) ?MailProvider { 226 + if (std.ascii.eqlIgnoreCase(value, "comail")) return .comail; 227 + if (std.ascii.eqlIgnoreCase(value, "resend")) return .resend; 190 228 return null; 191 229 } 192 230
+111 -9
src/core/mail.zig
··· 11 11 subject: []const u8, 12 12 text: []const u8, 13 13 ) !void { 14 - const api_key = config.resendApiKey() orelse { 15 - logMail(subject, to, handle, text); 16 - return; 14 + return switch (config.mailProvider()) { 15 + .comail => sendComail(io, allocator, to, handle, subject, text), 16 + .resend => sendResend(io, allocator, to, handle, subject, text), 17 17 }; 18 - const from = config.emailFrom() orelse { 19 - logMail(subject, to, handle, text); 20 - return; 18 + } 19 + 20 + fn sendComail( 21 + io: std.Io, 22 + allocator: std.mem.Allocator, 23 + to: []const u8, 24 + handle: []const u8, 25 + subject: []const u8, 26 + text: []const u8, 27 + ) !void { 28 + const maybe_api_key = config.comailApiKey(); 29 + const maybe_did = config.comailDid(); 30 + const maybe_from = config.emailFrom(); 31 + if (maybe_api_key == null or maybe_did == null or maybe_from == null) { 32 + return missingConfig( 33 + "comail", 34 + subject, 35 + to, 36 + handle, 37 + text, 38 + "missing_comail_api_key={} missing_comail_did={} missing_email_from={}", 39 + .{ maybe_api_key == null, maybe_did == null, maybe_from == null }, 40 + ); 41 + } 42 + 43 + const payload = try std.fmt.allocPrint( 44 + allocator, 45 + "{{\"from\":{f},\"to\":{f},\"subject\":{f},\"text\":{f},\"category\":\"verification\"}}", 46 + .{ std.json.fmt(maybe_from.?, .{}), std.json.fmt(to, .{}), std.json.fmt(subject, .{}), std.json.fmt(text, .{}) }, 47 + ); 48 + const authorization = try std.fmt.allocPrint(allocator, "Bearer {s}", .{maybe_api_key.?}); 49 + const headers = [_]std.http.Header{ 50 + .{ .name = "X-Atmos-DID", .value = maybe_did.? }, 21 51 }; 52 + var transport = zat.HttpTransport.init(io, allocator); 53 + defer transport.deinit(); 54 + const result = try transport.fetch(.{ 55 + .url = "https://smtp.atmos.email/v1/send", 56 + .method = .POST, 57 + .payload = payload, 58 + .authorization = authorization, 59 + .content_type = "application/json", 60 + .extra_headers = &headers, 61 + .max_response_size = 64 * 1024, 62 + }); 63 + defer allocator.free(result.body); 64 + return requireOk("comail", result.status, result.body); 65 + } 66 + 67 + fn sendResend( 68 + io: std.Io, 69 + allocator: std.mem.Allocator, 70 + to: []const u8, 71 + handle: []const u8, 72 + subject: []const u8, 73 + text: []const u8, 74 + ) !void { 75 + const maybe_api_key = config.resendApiKey(); 76 + const maybe_from = config.emailFrom(); 77 + if (maybe_api_key == null or maybe_from == null) { 78 + return missingConfig( 79 + "resend", 80 + subject, 81 + to, 82 + handle, 83 + text, 84 + "missing_resend_api_key={} missing_email_from={}", 85 + .{ maybe_api_key == null, maybe_from == null }, 86 + ); 87 + } 22 88 23 89 const payload = try std.fmt.allocPrint( 24 90 allocator, 25 91 "{{\"from\":{f},\"to\":[{f}],\"subject\":{f},\"text\":{f}}}", 26 - .{ std.json.fmt(from, .{}), std.json.fmt(to, .{}), std.json.fmt(subject, .{}), std.json.fmt(text, .{}) }, 92 + .{ std.json.fmt(maybe_from.?, .{}), std.json.fmt(to, .{}), std.json.fmt(subject, .{}), std.json.fmt(text, .{}) }, 27 93 ); 28 - const authorization = try std.fmt.allocPrint(allocator, "Bearer {s}", .{api_key}); 94 + const authorization = try std.fmt.allocPrint(allocator, "Bearer {s}", .{maybe_api_key.?}); 29 95 var transport = zat.HttpTransport.init(io, allocator); 30 96 defer transport.deinit(); 31 97 const result = try transport.fetch(.{ ··· 36 102 .content_type = "application/json", 37 103 .max_response_size = 64 * 1024, 38 104 }); 39 - if (@intFromEnum(result.status) < 200 or @intFromEnum(result.status) >= 300) return error.EmailDeliveryFailed; 105 + defer allocator.free(result.body); 106 + return requireOk("resend", result.status, result.body); 40 107 } 41 108 42 109 pub fn sendCode( ··· 59 126 fn logMail(subject: []const u8, email: []const u8, handle: []const u8, text: []const u8) void { 60 127 log.info("[zds mail] {s} for {s} <{s}>: {s}\n", .{ subject, handle, email, text }); 61 128 } 129 + 130 + fn missingConfig( 131 + comptime provider: []const u8, 132 + subject: []const u8, 133 + to: []const u8, 134 + handle: []const u8, 135 + text: []const u8, 136 + comptime fields_fmt: []const u8, 137 + fields: anytype, 138 + ) !void { 139 + if (isLocalDelivery()) { 140 + logMail(subject, to, handle, text); 141 + return; 142 + } 143 + log.err("mail delivery disabled provider=" ++ provider ++ " " ++ fields_fmt ++ " subject={s} to={s}\n", fields ++ .{ subject, to }); 144 + return error.EmailDeliveryFailed; 145 + } 146 + 147 + fn requireOk(comptime provider: []const u8, status: std.http.Status, body: []const u8) !void { 148 + const code = @intFromEnum(status); 149 + if (code >= 200 and code < 300) return; 150 + log.err("mail delivery failed provider=" ++ provider ++ " status={d} body={s}\n", .{ code, bodyExcerpt(body) }); 151 + return error.EmailDeliveryFailed; 152 + } 153 + 154 + fn bodyExcerpt(body: []const u8) []const u8 { 155 + return body[0..@min(body.len, 512)]; 156 + } 157 + 158 + fn isLocalDelivery() bool { 159 + const public_url = config.publicUrl(); 160 + return std.mem.startsWith(u8, public_url, "http://localhost") or 161 + std.mem.startsWith(u8, public_url, "http://127.0.0.1") or 162 + std.mem.startsWith(u8, public_url, "http://[::1]"); 163 + }
+35
src/internal/cli.zig
··· 9 9 plc_directory: ?[]const u8 = null, 10 10 plc_rotation_key: ?[]const u8 = null, 11 11 recovery_did_key: ?[]const u8 = null, 12 + mail_provider: ?[]const u8 = null, 12 13 email_from: ?[]const u8 = null, 13 14 resend_api_key: ?[]const u8 = null, 15 + comail_api_key: ?[]const u8 = null, 16 + comail_did: ?[]const u8 = null, 14 17 blob_upload_limit: ?usize = null, 15 18 blobstore_path: ?[]const u8 = null, 16 19 handle_domains: ?[]const u8 = null, ··· 44 47 MissingPlcDirectory, 45 48 MissingPlcRotationKey, 46 49 MissingRecoveryDidKey, 50 + MissingMailProvider, 47 51 MissingPort, 48 52 MissingPublicUrl, 49 53 MissingResendApiKey, 54 + MissingComailApiKey, 55 + MissingComailDid, 50 56 MissingServerDid, 51 57 UnknownArgument, 52 58 } || std.fmt.ParseIntError; ··· 61 67 .plc_directory = env("ZDS_PLC_DIRECTORY"), 62 68 .plc_rotation_key = env("ZDS_PLC_ROTATION_KEY"), 63 69 .recovery_did_key = env("ZDS_RECOVERY_DID_KEY"), 70 + .mail_provider = env("ZDS_MAIL_PROVIDER"), 64 71 .email_from = env("ZDS_EMAIL_FROM"), 65 72 .resend_api_key = env("ZDS_RESEND_API_KEY"), 73 + .comail_api_key = env("ZDS_COMAIL_API_KEY"), 74 + .comail_did = env("ZDS_COMAIL_DID"), 66 75 .blob_upload_limit = try envUsize("ZDS_BLOB_UPLOAD_LIMIT"), 67 76 .blobstore_path = env("ZDS_BLOBSTORE_PATH"), 68 77 .handle_domains = env("ZDS_HANDLE_DOMAINS"), ··· 94 103 std.debug.print( 95 104 \\usage: zds [--host HOST] [--port PORT] [--db PATH] [--public-url URL] [--server-did DID] 96 105 \\ [--plc-rotation-key KEY] [--recovery-did-key DIDKEY] 106 + \\ [--mail-provider comail|resend] [--email-from ADDRESS] 97 107 \\ [--blob-upload-limit BYTES] [--blobstore-path PATH] 98 108 \\ [--handle-domains DOMAINS] [--crawlers URLS] [--jwt-secret SECRET] 99 109 \\ [--proxy-service-did DID] [--proxy-service-id ID] [--proxy-service-url URL] ··· 149 159 options.recovery_did_key = args.next() orelse return error.MissingRecoveryDidKey; 150 160 return true; 151 161 } 162 + if (std.mem.eql(u8, arg, "--mail-provider")) { 163 + options.mail_provider = args.next() orelse return error.MissingMailProvider; 164 + return true; 165 + } 152 166 if (std.mem.eql(u8, arg, "--email-from")) { 153 167 options.email_from = args.next() orelse return error.MissingEmailFrom; 154 168 return true; ··· 157 171 options.resend_api_key = args.next() orelse return error.MissingResendApiKey; 158 172 return true; 159 173 } 174 + if (std.mem.eql(u8, arg, "--comail-api-key")) { 175 + options.comail_api_key = args.next() orelse return error.MissingComailApiKey; 176 + return true; 177 + } 178 + if (std.mem.eql(u8, arg, "--comail-did")) { 179 + options.comail_did = args.next() orelse return error.MissingComailDid; 180 + return true; 181 + } 160 182 if (std.mem.eql(u8, arg, "--blob-upload-limit")) { 161 183 options.blob_upload_limit = try std.fmt.parseInt(usize, args.next() orelse return error.MissingBlobUploadLimit, 10); 162 184 return true; ··· 231 253 .{ .flag = "--plc-directory=", .field = "plc_directory" }, 232 254 .{ .flag = "--plc-rotation-key=", .field = "plc_rotation_key" }, 233 255 .{ .flag = "--recovery-did-key=", .field = "recovery_did_key" }, 256 + .{ .flag = "--mail-provider=", .field = "mail_provider" }, 234 257 .{ .flag = "--email-from=", .field = "email_from" }, 235 258 .{ .flag = "--resend-api-key=", .field = "resend_api_key" }, 259 + .{ .flag = "--comail-api-key=", .field = "comail_api_key" }, 260 + .{ .flag = "--comail-did=", .field = "comail_did" }, 236 261 .{ .flag = "--blobstore-path=", .field = "blobstore_path" }, 237 262 .{ .flag = "--handle-domains=", .field = "handle_domains" }, 238 263 .{ .flag = "--crawlers=", .field = "crawlers" }, ··· 277 302 "--plc-rotation-key=00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", 278 303 "--recovery-did-key", 279 304 "did:key:zRecovery", 305 + "--mail-provider=comail", 306 + "--comail-api-key", 307 + "atmos_test", 308 + "--comail-did=did:plc:mailer", 309 + "--email-from", 310 + "ZDS <noreply@example.com>", 280 311 "--debug", 281 312 }; 282 313 const init = std.process.Init{ .minimal = .{ .args = &argv } }; ··· 288 319 try std.testing.expectEqualStrings("dev/test.sqlite3", options.db_path); 289 320 try std.testing.expectEqualStrings("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", options.plc_rotation_key.?); 290 321 try std.testing.expectEqualStrings("did:key:zRecovery", options.recovery_did_key.?); 322 + try std.testing.expectEqualStrings("comail", options.mail_provider.?); 323 + try std.testing.expectEqualStrings("atmos_test", options.comail_api_key.?); 324 + try std.testing.expectEqualStrings("did:plc:mailer", options.comail_did.?); 325 + try std.testing.expectEqualStrings("ZDS <noreply@example.com>", options.email_from.?); 291 326 try std.testing.expectEqualStrings("debug", options.log_level.?); 292 327 } 293 328
+6
src/main.zig
··· 27 27 if (options.plc_directory) |value| zds.core.config.setPlcDirectory(value); 28 28 zds.core.config.setPlcRotationKey(options.plc_rotation_key); 29 29 zds.core.config.setRecoveryDidKey(options.recovery_did_key); 30 + if (options.mail_provider) |value| { 31 + const parsed = zds.core.config.parseMailProvider(value) orelse return error.InvalidMailProvider; 32 + zds.core.config.setMailProvider(parsed); 33 + } 30 34 zds.core.config.setEmailFrom(options.email_from); 31 35 zds.core.config.setResendApiKey(options.resend_api_key); 36 + zds.core.config.setComailApiKey(options.comail_api_key); 37 + zds.core.config.setComailDid(options.comail_did); 32 38 if (options.blob_upload_limit) |value| zds.core.config.setBlobUploadLimit(value); 33 39 if (options.blobstore_path) |value| zds.core.config.setBlobstorePath(value); 34 40 if (options.handle_domains) |value| zds.core.config.setHandleDomains(value);